github.com/ianlewis/go-gitignore@v0.1.1-0.20231110021210-4a0f15cbd56f/repository.go (about)

     1  // Copyright 2016 Denormal Limited
     2  // Copyright 2020 Christian Muehlhaeuser <muesli@gmail.com>
     3  // Copyright 2023 Google LLC
     4  //
     5  // Licensed under the Apache License, Version 2.0 (the "License");
     6  // you may not use this file except in compliance with the License.
     7  // You may obtain a copy of the License at
     8  //
     9  //      http://www.apache.org/licenses/LICENSE-2.0
    10  //
    11  // Unless required by applicable law or agreed to in writing, software
    12  // distributed under the License is distributed on an "AS IS" BASIS,
    13  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    14  // See the License for the specific language governing permissions and
    15  // limitations under the License.
    16  
    17  package gitignore
    18  
    19  import (
    20  	"os"
    21  	"path/filepath"
    22  	"strings"
    23  )
    24  
    25  const File = ".gitignore"
    26  
    27  // repository is the implementation of the set of .gitignore files within a
    28  // repository hierarchy
    29  type repository struct {
    30  	ignore
    31  	_errors  func(e Error) bool
    32  	_cache   Cache
    33  	_file    string
    34  	_exclude GitIgnore
    35  } // repository{}
    36  
    37  // NewRepository returns a GitIgnore instance representing a git repository
    38  // with root directory base. If base is not a directory, or base cannot be
    39  // read, NewRepository will return an error.
    40  //
    41  // Internally, NewRepository uses NewRepositoryWithFile.
    42  func NewRepository(base string) (GitIgnore, error) {
    43  	return NewRepositoryWithFile(base, File)
    44  } // NewRepository()
    45  
    46  // NewRepositoryWithFile returns a GitIgnore instance representing a git
    47  // repository with root directory base. The repository will use file as
    48  // the name of the files within the repository from which to load the
    49  // .gitignore patterns. If file is the empty string, NewRepositoryWithFile
    50  // uses ".gitignore". If the ignore file name is ".gitignore", the returned
    51  // GitIgnore instance will also consider patterns listed in
    52  // $GIT_DIR/info/exclude when performing repository matching.
    53  //
    54  // Internally, NewRepositoryWithFile uses NewRepositoryWithErrors.
    55  func NewRepositoryWithFile(base, file string) (GitIgnore, error) {
    56  	// define an error handler to catch any file access errors
    57  	//		- record the first encountered error
    58  	var _error Error
    59  	_errors := func(e Error) bool {
    60  		if _error == nil {
    61  			_error = e
    62  		}
    63  		return true
    64  	}
    65  
    66  	// attempt to retrieve the repository represented by this file
    67  	_repository := NewRepositoryWithErrors(base, file, _errors)
    68  
    69  	// did we encounter an error?
    70  	//		- if the error has a zero Position then it was encountered
    71  	//		  before parsing was attempted, so we return that error
    72  	if _error != nil {
    73  		if _error.Position().Zero() {
    74  			return nil, _error.Underlying()
    75  		}
    76  	}
    77  
    78  	// otherwise, we ignore the parser errors
    79  	return _repository, nil
    80  } // NewRepositoryWithFile()
    81  
    82  // NewRepositoryWithErrors returns a GitIgnore instance representing a git
    83  // repository with a root directory base. As with NewRepositoryWithFile, file
    84  // specifies the name of the files within the repository containing the
    85  // .gitignore patterns, and defaults to ".gitignore" if file is not specified.
    86  // If the ignore file name is ".gitignore", the returned GitIgnore instance
    87  // will also consider patterns listed in $GIT_DIR/info/exclude when performing
    88  // repository matching.
    89  //
    90  // If errors is given, it will be invoked for each error encountered while
    91  // matching a path against the repository GitIgnore (such as file permission
    92  // denied, or errors during .gitignore parsing). See Match below.
    93  //
    94  // Internally, NewRepositoryWithErrors uses NewRepositoryWithCache.
    95  func NewRepositoryWithErrors(base, file string, errors func(e Error) bool) GitIgnore {
    96  	return NewRepositoryWithCache(base, file, NewCache(), errors)
    97  } // NewRepositoryWithErrors()
    98  
    99  // NewRepositoryWithCache returns a GitIgnore instance representing a git
   100  // repository with a root directory base. As with NewRepositoryWithErrors,
   101  // file specifies the name of the files within the repository containing the
   102  // .gitignore patterns, and defaults to ".gitignore" if file is not specified.
   103  // If the ignore file name is ".gitignore", the returned GitIgnore instance
   104  // will also consider patterns listed in $GIT_DIR/info/exclude when performing
   105  // repository matching.
   106  //
   107  // NewRepositoryWithCache will attempt to load each .gitignore within the
   108  // repository only once, using NewWithCache to store the corresponding
   109  // GitIgnore instance in cache. If cache is given as nil,
   110  // NewRepositoryWithCache will create a Cache instance for this repository.
   111  //
   112  // If errors is given, it will be invoked for each error encountered while
   113  // matching a path against the repository GitIgnore (such as file permission
   114  // denied, or errors during .gitignore parsing). See Match below.
   115  func NewRepositoryWithCache(base, file string, cache Cache, errors func(e Error) bool) GitIgnore {
   116  	// do we have an error handler?
   117  	_errors := errors
   118  	if _errors == nil {
   119  		_errors = func(e Error) bool { return true }
   120  	}
   121  
   122  	// extract the absolute path of the base directory
   123  	_base, _err := filepath.Abs(base)
   124  	if _err != nil {
   125  		_errors(NewError(_err, Position{}))
   126  		return nil
   127  	}
   128  
   129  	// ensure the given base is a directory
   130  	_info, _err := os.Stat(_base)
   131  	if _info != nil {
   132  		if !_info.IsDir() {
   133  			_err = InvalidDirectoryError
   134  		}
   135  	}
   136  	if _err != nil {
   137  		_errors(NewError(_err, Position{}))
   138  		return nil
   139  	}
   140  
   141  	// if we haven't been given a base file name, use the default
   142  	if file == "" {
   143  		file = File
   144  	}
   145  
   146  	// are we matching .gitignore files?
   147  	//		- if we are, we also consider $GIT_DIR/info/exclude
   148  	var _exclude GitIgnore
   149  	if file == File {
   150  		_exclude, _err = exclude(_base)
   151  		if _err != nil {
   152  			_errors(NewError(_err, Position{}))
   153  			return nil
   154  		}
   155  	}
   156  
   157  	// create the repository instance
   158  	_ignore := ignore{_base: _base}
   159  	_repository := &repository{
   160  		ignore:   _ignore,
   161  		_errors:  _errors,
   162  		_exclude: _exclude,
   163  		_cache:   cache,
   164  		_file:    file,
   165  	}
   166  
   167  	return _repository
   168  } // NewRepositoryWithCache()
   169  
   170  // Match attempts to match the path against this repository. Matching proceeds
   171  // according to normal gitignore rules, where .gtignore files in the same
   172  // directory as path, take precedence over .gitignore files higher up the
   173  // path hierarchy, and child files and directories are ignored if the parent
   174  // is ignored. If the path is matched by a gitignore pattern in the repository,
   175  // a Match is returned detailing the matched pattern. The returned Match
   176  // can be used to determine if the path should be ignored or included according
   177  // to the repository.
   178  //
   179  // If an error is encountered during matching, the repository error handler
   180  // (if configured via NewRepositoryWithErrors or NewRepositoryWithCache), will
   181  // be called. If the error handler returns false, matching will terminate and
   182  // Match will return nil. If handler returns true, Match will continue
   183  // processing in an attempt to match path.
   184  //
   185  // Match will raise an error and return nil if the absolute path cannot be
   186  // determined, or if its not possible to determine if path represents a file
   187  // or a directory.
   188  //
   189  // If path is not located under the root of this repository, Match returns nil.
   190  func (r *repository) Match(path string) Match {
   191  	// ensure we have the absolute path for the given file
   192  	_path, _err := filepath.Abs(path)
   193  	if _err != nil {
   194  		r._errors(NewError(_err, Position{}))
   195  		return nil
   196  	}
   197  
   198  	// is the path a file or a directory?
   199  	_info, _err := os.Stat(_path)
   200  	if _err != nil {
   201  		r._errors(NewError(_err, Position{}))
   202  		return nil
   203  	}
   204  	_isdir := _info.IsDir()
   205  
   206  	// attempt to match the absolute path
   207  	return r.Absolute(_path, _isdir)
   208  } // Match()
   209  
   210  // Absolute attempts to match an absolute path against this repository. If the
   211  // path is not located under the base directory of this repository, or is not
   212  // matched by this repository, nil is returned.
   213  func (r *repository) Absolute(path string, isdir bool) Match {
   214  	// does the file share the same directory as this ignore file?
   215  	if !strings.HasPrefix(path, r.Base()) {
   216  		return nil
   217  	}
   218  
   219  	// extract the relative path of this file
   220  	_rel, err := filepath.Rel(r.Base(), path)
   221  	if err != nil {
   222  		return nil
   223  	}
   224  
   225  	return r.Relative(_rel, isdir)
   226  } // Absolute()
   227  
   228  // Relative attempts to match a path relative to the repository base directory.
   229  // If the path is not matched by the repository, nil is returned.
   230  func (r *repository) Relative(path string, isdir bool) Match {
   231  	// if there's no path, then there's nothing to match
   232  	_path := filepath.Clean(path)
   233  	if _path == "." {
   234  		return nil
   235  	}
   236  
   237  	// repository matching:
   238  	//		- a child path cannot be considered if its parent is ignored
   239  	//		- a .gitignore in a lower directory overrides a .gitignore in a
   240  	//		  higher directory
   241  
   242  	// first, is the parent directory ignored?
   243  	//		- extract the parent directory from the current path
   244  	_parent, _local := filepath.Split(_path)
   245  	_match := r.Relative(_parent, true)
   246  	if _match != nil {
   247  		if _match.Ignore() {
   248  			return _match
   249  		}
   250  	}
   251  	_parent = filepath.Clean(_parent)
   252  
   253  	// the parent directory isn't ignored, so we now look at the original path
   254  	//		- we consider .gitignore files in the current directory first, then
   255  	//		  move up the path hierarchy
   256  	var _last string
   257  	for {
   258  		_file := filepath.Join(r._base, _parent, r._file)
   259  		_ignore := NewWithCache(_file, r._cache, r._errors)
   260  		if _ignore != nil {
   261  			_match := _ignore.Relative(_local, isdir)
   262  			if _match != nil {
   263  				return _match
   264  			}
   265  		}
   266  
   267  		// if there's no parent, then we're done
   268  		//		- since we use filepath.Clean() we look for "."
   269  		if _parent == "." {
   270  			break
   271  		}
   272  
   273  		// we don't have a match for this file, so we progress up the
   274  		// path hierarchy
   275  		//		- we are manually building _local using the .gitignore
   276  		//		  separator "/", which is how we handle operating system
   277  		//		  file system differences
   278  		_parent, _last = filepath.Split(_parent)
   279  		_parent = filepath.Clean(_parent)
   280  		_local = _last + string(_SEPARATOR) + _local
   281  	}
   282  
   283  	// do we have a global exclude file? (i.e. GIT_DIR/info/exclude)
   284  	if r._exclude != nil {
   285  		return r._exclude.Relative(path, isdir)
   286  	}
   287  
   288  	// we have no match
   289  	return nil
   290  } // Relative()
   291  
   292  // ensure repository satisfies the GitIgnore interface
   293  var _ GitIgnore = &repository{}