Skip to content

Commit a125051

Browse files
committed
Make LFS ignoring more robust
* Check for `.gitattributes` files in subdirectories and also use them * Do not depend on running commands in the repo root anymore
1 parent babb9bd commit a125051

File tree

3 files changed

+73
-68
lines changed

3 files changed

+73
-68
lines changed

cli/src/commands/status.rs

+8-5
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,17 @@ pub(crate) fn cmd_status(
6262
let mut formatter = ui.stdout_formatter();
6363
let formatter = formatter.as_mut();
6464

65-
let matcher = DifferenceMatcher::new(
66-
matcher,
67-
GitAttributesMatcher::new().expect("gitattributes matcher failed"),
68-
);
69-
7065
if let Some(wc_commit) = &maybe_wc_commit {
7166
let parent_tree = wc_commit.parent_tree(repo.as_ref())?;
7267
let tree = wc_commit.tree()?;
68+
69+
let root = workspace_command.workspace().workspace_root();
70+
let matcher = DifferenceMatcher::new(
71+
matcher,
72+
GitAttributesMatcher::new(tree.as_merge().first(), root)
73+
.expect("gitattributes matcher failed"),
74+
);
75+
7376
if tree.id() == parent_tree.id() {
7477
writeln!(formatter, "The working copy is clean")?;
7578
} else {

lib/src/local_working_copy.rs

+7-1
Original file line numberDiff line numberDiff line change
@@ -761,7 +761,13 @@ impl TreeState {
761761
}
762762

763763
fn git_attributes_matcher(&self) -> Box<dyn Matcher> {
764-
Box::new(GitAttributesMatcher::new().expect("failed to init GitAttributesMatcher"))
764+
let merged_tree = self.current_tree().unwrap();
765+
let tree = merged_tree.as_merge().first();
766+
767+
Box::new(
768+
GitAttributesMatcher::new(tree, self.working_copy_path())
769+
.expect("failed to init GitAttributesMatcher"),
770+
)
765771
}
766772

767773
pub fn init(

lib/src/matchers.rs

+58-62
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,16 @@ use std::fs::File;
2222
use std::io;
2323
use std::io::BufRead;
2424
use std::io::BufReader;
25-
use std::io::ErrorKind;
2625
use std::iter;
2726
use std::path::Path;
2827

29-
use ignore::gitignore;
3028
use itertools::Itertools as _;
3129
use thiserror::Error;
3230
use tracing::instrument;
3331

3432
use crate::repo_path::RepoPath;
3533
use crate::repo_path::RepoPathComponentBuf;
34+
use crate::tree::Tree;
3635

3736
#[derive(PartialEq, Eq, Debug)]
3837
pub enum Visit {
@@ -514,14 +513,23 @@ impl<V: Debug> Debug for RepoPathTree<V> {
514513
}
515514
}
516515

516+
#[derive(Clone, Debug)]
517+
struct IgnoreWrapper(ignore::gitignore::Gitignore);
518+
519+
impl Default for IgnoreWrapper {
520+
fn default() -> Self {
521+
Self(ignore::gitignore::Gitignore::empty())
522+
}
523+
}
524+
517525
/// Matches file paths with glob patterns.
518526
///
519527
/// Patterns are provided as `(dir, pattern)` pairs, where `dir` should be the
520528
/// longest directory path that contains no glob meta characters, and `pattern`
521529
/// will be evaluated relative to `dir`.
522530
#[derive(Clone, Debug)]
523531
pub struct GitAttributesMatcher {
524-
matcher: gitignore::Gitignore,
532+
tree: RepoPathTree<IgnoreWrapper>,
525533
}
526534

527535
#[derive(Debug, Error)]
@@ -534,10 +542,9 @@ pub enum GitAttributesMatcherError {
534542
InvalidPattern(String),
535543
}
536544

537-
fn parse_gitignore(
545+
fn parse_gitattributes(
538546
f: File,
539-
root: impl AsRef<Path>,
540-
builder: &mut gitignore::GitignoreBuilder,
547+
builder: &mut ignore::gitignore::GitignoreBuilder,
541548
) -> Result<(), GitAttributesMatcherError> {
542549
let reader = BufReader::new(f);
543550

@@ -549,6 +556,11 @@ fn parse_gitignore(
549556
None => continue,
550557
};
551558

559+
// lines starting with # are ignored
560+
if pattern.starts_with('#') {
561+
continue;
562+
}
563+
552564
// Simple heuristic to detect LFS files
553565
let is_lfs = parts.any(|p| p == "filter=lfs");
554566
if !is_lfs {
@@ -563,80 +575,64 @@ fn parse_gitignore(
563575
"negative patterns are not allowed in .gitattributes files".to_string(),
564576
));
565577
}
578+
579+
// git somehow accepts this but ignores it
566580
if pattern.ends_with("/") {
567-
return Err(GitAttributesMatcherError::InvalidPattern(format!(
568-
"trailing slash does not match a directory in .gitattributes, try {}** instead",
569-
pattern
570-
)));
581+
continue;
571582
}
572-
573-
builder.add_line(Some(root.as_ref().to_path_buf()), pattern)?;
583+
builder.add_line(None, pattern)?;
574584
}
575585

576586
Ok(())
577587
}
578588

579589
impl GitAttributesMatcher {
580590
/// Create a new matcher for the contents of a .gitattributes file
581-
pub fn new() -> Result<Self, GitAttributesMatcherError> {
582-
// TODO(brandon): We shouldn't assume `jj` is being run from the root.
583-
let root = Path::new(".");
584-
let mut builder = gitignore::GitignoreBuilder::new(&root);
585-
586-
let gitattributes_path = root.join(".gitattributes");
587-
588-
match File::open(gitattributes_path) {
589-
Ok(f) => {
590-
parse_gitignore(f, &root, &mut builder)?;
591-
}
592-
Err(e) => {
593-
// If the file doesn't exist, we'll create a blank Gitignore,
594-
// equivalent to Gitignore::empty()
595-
if e.kind() != ErrorKind::NotFound {
596-
return Err(GitAttributesMatcherError::ReadError(e));
597-
}
591+
pub fn new(tree: &Tree, repo_root: &Path) -> Result<Self, GitAttributesMatcherError> {
592+
let git_attributes_matcher = FileGlobsMatcher::new([(
593+
tree.dir(),
594+
glob::Pattern::new("**/.gitattributes")
595+
.expect("This is a statically known valid pattern"),
596+
)]);
597+
let entries = tree.entries_matching(&git_attributes_matcher);
598+
let mut tree = RepoPathTree::default();
599+
for (path, _) in entries {
600+
let gitattributes_path = path
601+
.to_fs_path(repo_root)
602+
.expect("The file matcher only returns valid paths");
603+
604+
if gitattributes_path.exists() {
605+
let relative_path = path
606+
.parent()
607+
.expect("There is always a parent directory for files")
608+
.to_owned();
609+
let location = repo_root.join(relative_path.to_internal_dir_string());
610+
let mut builder = ignore::gitignore::GitignoreBuilder::new(location);
611+
let file = File::open(&gitattributes_path)?;
612+
613+
parse_gitattributes(file, &mut builder)?;
614+
tree.add(&relative_path).value = IgnoreWrapper(builder.build()?);
598615
}
599616
}
600-
601-
Ok(GitAttributesMatcher {
602-
matcher: builder.build()?,
603-
})
617+
Ok(GitAttributesMatcher { tree })
604618
}
605619
}
606620

607621
impl Matcher for GitAttributesMatcher {
608622
fn matches(&self, file: &RepoPath) -> bool {
609-
// TODO(brandon): Figure out if we need to handle is_dir
610-
let path = match file.to_fs_path(self.matcher.path()) {
611-
Ok(p) => p,
612-
Err(_) => return false,
613-
};
614-
let m_match = self.matcher.matched(&path, false);
615-
match m_match {
616-
ignore::Match::None => false,
617-
ignore::Match::Ignore(_) => {
618-
tracing::debug!(?m_match, ?path, "matching a file");
619-
true
620-
}
621-
ignore::Match::Whitelist(_) => false,
622-
}
623+
self.tree
624+
.walk_to(file)
625+
.take_while(|(_, tail_path)| !tail_path.is_root())
626+
.any(|(sub, tail_path)| {
627+
let Ok(path) = tail_path.to_fs_path(sub.value.0.path()) else {
628+
return false;
629+
};
630+
matches!(sub.value.0.matched(path, false), ignore::Match::Ignore(_))
631+
})
623632
}
624633

625-
fn visit(&self, dir: &RepoPath) -> Visit {
626-
let dir = match dir.to_fs_path(self.matcher.path()) {
627-
Ok(p) => p,
628-
Err(_) => return Visit::Nothing,
629-
};
630-
// TODO(brandon): Try to understand if these make sense
631-
let m_match = self.matcher.matched(&dir, true);
632-
match m_match {
633-
ignore::Match::None => Visit::Nothing,
634-
ignore::Match::Ignore(_) => {
635-
tracing::debug!(?m_match, ?dir, "visiting a dir");
636-
Visit::AllRecursively
637-
}
638-
ignore::Match::Whitelist(_) => Visit::Nothing,
639-
}
634+
fn visit(&self, _dir: &RepoPath) -> Visit {
635+
Visit::Nothing
640636
}
641637
}
642638

0 commit comments

Comments
 (0)