mirror of https://github.com/databricks/cli.git
Take into account gitignore rules defined in parent directories (#182)
This change introduces `git.View`. View represents a view on a directory tree that takes into account all applicable .gitignore files. The directory tree does NOT need to be the repository root. For example: with a repository root at "myrepo", a view can be anchored at "myrepo/someproject" and still respect the ignore rules defined at "myrepo/.gitignore". We use this functionality to synchronize files from a path nested in a repository while respecting the repository's ignore rules. Co-authored-by: Serge Smertin <259697+nfx@users.noreply.github.com>
This commit is contained in:
parent
df7b341afe
commit
2eb10800a5
|
@ -0,0 +1,208 @@
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/databricks/bricks/folders"
|
||||||
|
ignore "github.com/sabhiram/go-gitignore"
|
||||||
|
)
|
||||||
|
|
||||||
|
const gitIgnoreFileName = ".gitignore"
|
||||||
|
|
||||||
|
// Repository represents a Git repository or a directory
|
||||||
|
// that could later be initialized as Git repository.
|
||||||
|
type Repository struct {
|
||||||
|
// real indicates if this is a real repository or a non-Git
|
||||||
|
// directory where we process .gitignore files.
|
||||||
|
real bool
|
||||||
|
|
||||||
|
// rootPath is the absolute path to the repository root.
|
||||||
|
rootPath string
|
||||||
|
|
||||||
|
// ignore contains a list of ignore patterns indexed by the
|
||||||
|
// path prefix relative to the repository root.
|
||||||
|
//
|
||||||
|
// Example prefixes: ".", "foo/bar"
|
||||||
|
//
|
||||||
|
// Note: prefixes use the forward slash instead of the
|
||||||
|
// OS-specific path separator. This matches Git convention.
|
||||||
|
ignore map[string][]*ignore.GitIgnore
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) includeIgnoreFile(relativeIgnoreFilePath, relativeTo string) error {
|
||||||
|
absPath := filepath.Join(r.rootPath, relativeIgnoreFilePath)
|
||||||
|
|
||||||
|
// The file must be stat-able and not a directory.
|
||||||
|
// If it doesn't exist or is a directory, do nothing.
|
||||||
|
stat, err := os.Stat(absPath)
|
||||||
|
if err != nil || stat.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ignore, err := ignore.CompileIgnoreFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
relativeTo = path.Clean(filepath.ToSlash(relativeTo))
|
||||||
|
r.ignore[relativeTo] = append(r.ignore[relativeTo], ignore)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include ignore files in directories that are parent to `relPath`.
|
||||||
|
//
|
||||||
|
// If equal to "foo/bar" this loads ignore files
|
||||||
|
// located at the repository root and in the directory "foo".
|
||||||
|
//
|
||||||
|
// If equal to "." this function does nothing.
|
||||||
|
func (r *Repository) includeIgnoreFilesUpToPath(relPath string) error {
|
||||||
|
// Accumulate list of directories to load ignore file from.
|
||||||
|
paths := []string{
|
||||||
|
".",
|
||||||
|
}
|
||||||
|
for _, path := range strings.Split(relPath, string(os.PathSeparator)) {
|
||||||
|
paths = append(paths, filepath.Join(paths[len(paths)-1], path))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load ignore files.
|
||||||
|
for _, path := range paths {
|
||||||
|
// Path equal to `relPath` is loaded by [includeIgnoreFilesUnderPath].
|
||||||
|
if path == relPath {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err := r.includeIgnoreFile(filepath.Join(path, gitIgnoreFileName), path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include ignore files in directories that are equal to or nested under `relPath`.
|
||||||
|
func (r *Repository) includeIgnoreFilesUnderPath(relPath string) error {
|
||||||
|
absPath := filepath.Join(r.rootPath, relPath)
|
||||||
|
err := filepath.WalkDir(absPath, r.includeIgnoreFilesWalkDirFn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to walk directory: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// includeIgnoreFilesWalkDirFn is called from [filepath.WalkDir] in includeIgnoreFilesUnderPath.
|
||||||
|
func (r *Repository) includeIgnoreFilesWalkDirFn(absPath string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
// If reading the target path fails bubble up the error.
|
||||||
|
if d == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Ignore failure to read paths nested under the target path.
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get path relative to root path.
|
||||||
|
pathRelativeToRoot, err := filepath.Rel(r.rootPath, absPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if directory is ignored before recursing into it.
|
||||||
|
if d.IsDir() && r.Ignore(pathRelativeToRoot) {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load .gitignore if we find one.
|
||||||
|
if d.Name() == gitIgnoreFileName {
|
||||||
|
err := r.includeIgnoreFile(pathRelativeToRoot, filepath.Dir(pathRelativeToRoot))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include ignore files relevant for files nested under `relPath`.
|
||||||
|
func (r *Repository) includeIgnoreFilesForPath(relPath string) error {
|
||||||
|
err := r.includeIgnoreFilesUpToPath(relPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.includeIgnoreFilesUnderPath(relPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore computes whether to ignore the specified path.
|
||||||
|
// The specified path is relative to the repository root path.
|
||||||
|
func (r *Repository) Ignore(relPath string) bool {
|
||||||
|
parts := strings.Split(filepath.ToSlash(relPath), "/")
|
||||||
|
|
||||||
|
// Retain trailing slash for directory patterns.
|
||||||
|
// We know a trailing slash was present if the last element
|
||||||
|
// after splitting is an empty string.
|
||||||
|
trailingSlash := ""
|
||||||
|
if parts[len(parts)-1] == "" {
|
||||||
|
parts = parts[:len(parts)-1]
|
||||||
|
trailingSlash = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk over path prefixes to check applicable gitignore files.
|
||||||
|
for i := range parts {
|
||||||
|
prefix := path.Clean(strings.Join(parts[:i], "/"))
|
||||||
|
suffix := path.Clean(strings.Join(parts[i:], "/")) + trailingSlash
|
||||||
|
|
||||||
|
// For this prefix (e.g. ".", or "dir1/dir2") we check if the
|
||||||
|
// suffix is matched in the respective ignore files.
|
||||||
|
fs, ok := r.ignore[prefix]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, f := range fs {
|
||||||
|
if f.MatchesPath(suffix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepository(path string) (*Repository, error) {
|
||||||
|
path, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
real := true
|
||||||
|
rootPath, err := folders.FindDirWithLeaf(path, ".git")
|
||||||
|
if err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Cannot find `.git` directory.
|
||||||
|
// Treat the specified path as a potential repository root.
|
||||||
|
real = false
|
||||||
|
rootPath = path
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := &Repository{
|
||||||
|
real: real,
|
||||||
|
rootPath: rootPath,
|
||||||
|
ignore: make(map[string][]*ignore.GitIgnore),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always ignore ".git" directory.
|
||||||
|
repo.ignore["."] = append(repo.ignore["."], ignore.CompileIgnoreLines(".git"))
|
||||||
|
|
||||||
|
// Load repository-wide excludes file.
|
||||||
|
err = repo.includeIgnoreFile(filepath.Join(".git", "info", "excludes"), ".")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return repo, nil
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRepository(t *testing.T) {
|
||||||
|
// Load this repository as test.
|
||||||
|
repo, err := NewRepository("..")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Load all .gitignore files in this repository.
|
||||||
|
err = repo.includeIgnoreFilesForPath(".")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Check that top level ignores work.
|
||||||
|
assert.True(t, repo.Ignore(".DS_Store"))
|
||||||
|
assert.True(t, repo.Ignore("foo.pyc"))
|
||||||
|
assert.False(t, repo.Ignore("vendor"))
|
||||||
|
assert.True(t, repo.Ignore("vendor/"))
|
||||||
|
|
||||||
|
// Check that ignores under testdata work.
|
||||||
|
assert.True(t, repo.Ignore(filepath.Join("git", "testdata", "root.ignoreme")))
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
root.*
|
||||||
|
/root/foo
|
||||||
|
**/root_double
|
||||||
|
|
||||||
|
# Don't recurse into this directory.
|
||||||
|
/ignorethis
|
||||||
|
|
||||||
|
# Directory pattern.
|
||||||
|
ignoredirectory/
|
|
@ -0,0 +1,2 @@
|
||||||
|
a.*
|
||||||
|
**/a_double
|
|
@ -0,0 +1,2 @@
|
||||||
|
b.*
|
||||||
|
**/b_double
|
|
@ -0,0 +1,68 @@
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// View represents a view on a directory tree that takes into account
|
||||||
|
// all applicable .gitignore files. The directory tree does NOT need
|
||||||
|
// to be the repository root.
|
||||||
|
//
|
||||||
|
// For example: with a repository root at "myrepo", a view can be
|
||||||
|
// anchored at "myrepo/someproject" and still respect the ignore
|
||||||
|
// rules defined at "myrepo/.gitignore".
|
||||||
|
//
|
||||||
|
// We use this functionality to synchronize files from a path nested
|
||||||
|
// in a repository while respecting the repository's ignore rules.
|
||||||
|
type View struct {
|
||||||
|
repo *Repository
|
||||||
|
|
||||||
|
// targetPath is the relative path within the repository we care about.
|
||||||
|
// For example: "." or "a/b".
|
||||||
|
targetPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore computes whether to ignore the specified path.
|
||||||
|
// The specified path is relative to the view's target path.
|
||||||
|
func (v *View) Ignore(path string) bool {
|
||||||
|
path = filepath.ToSlash(path)
|
||||||
|
|
||||||
|
// Retain trailing slash for directory patterns.
|
||||||
|
// Needs special handling because it is removed by path cleaning.
|
||||||
|
trailingSlash := ""
|
||||||
|
if strings.HasSuffix(path, "/") {
|
||||||
|
trailingSlash = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.repo.Ignore(filepath.Join(v.targetPath, path) + trailingSlash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewView(path string) (*View, error) {
|
||||||
|
path, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, err := NewRepository(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target path must be relative to the repository root path.
|
||||||
|
targetPath, err := filepath.Rel(repo.rootPath, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load ignore files relevant for this view's path.
|
||||||
|
err = repo.includeIgnoreFilesForPath(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &View{
|
||||||
|
repo: repo,
|
||||||
|
targetPath: targetPath,
|
||||||
|
}, nil
|
||||||
|
}
|
|
@ -0,0 +1,186 @@
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func copyTestdata(t *testing.T) string {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Copy everything under "testdata" to temporary directory.
|
||||||
|
err := filepath.WalkDir("testdata", func(path string, d fs.DirEntry, err error) error {
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
if d.IsDir() {
|
||||||
|
err := os.MkdirAll(filepath.Join(tempDir, path), 0755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fin, err := os.Open(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer fin.Close()
|
||||||
|
|
||||||
|
fout, err := os.Create(filepath.Join(tempDir, path))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer fout.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(fout, fin)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
return filepath.Join(tempDir, "testdata")
|
||||||
|
}
|
||||||
|
|
||||||
|
func createFakeRepo(t *testing.T) string {
|
||||||
|
absPath := copyTestdata(t)
|
||||||
|
|
||||||
|
// Add .git directory to make it look like a Git repository.
|
||||||
|
err := os.Mkdir(filepath.Join(absPath, ".git"), 0755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return absPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func testViewAtRoot(t *testing.T, v *View) {
|
||||||
|
// Check .gitignore at root.
|
||||||
|
assert.True(t, v.Ignore("root.sh"))
|
||||||
|
assert.True(t, v.Ignore("root/foo"))
|
||||||
|
assert.True(t, v.Ignore("root_double"))
|
||||||
|
assert.False(t, v.Ignore("newfile"))
|
||||||
|
assert.True(t, v.Ignore("ignoredirectory/"))
|
||||||
|
|
||||||
|
// Nested .gitignores should not affect root.
|
||||||
|
assert.False(t, v.Ignore("a.sh"))
|
||||||
|
|
||||||
|
// Nested .gitignores should apply in their path.
|
||||||
|
assert.True(t, v.Ignore("a/a.sh"))
|
||||||
|
assert.True(t, v.Ignore("a/whatever/a.sh"))
|
||||||
|
|
||||||
|
// .git must always be ignored.
|
||||||
|
assert.True(t, v.Ignore(".git"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewRootInBricksRepo(t *testing.T) {
|
||||||
|
v, err := NewView("./testdata")
|
||||||
|
require.NoError(t, err)
|
||||||
|
testViewAtRoot(t, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewRootInTempRepo(t *testing.T) {
|
||||||
|
v, err := NewView(createFakeRepo(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
testViewAtRoot(t, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewRootInTempDir(t *testing.T) {
|
||||||
|
v, err := NewView(copyTestdata(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
testViewAtRoot(t, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testViewAtA(t *testing.T, v *View) {
|
||||||
|
// Inherit .gitignore from root.
|
||||||
|
assert.True(t, v.Ignore("root.sh"))
|
||||||
|
assert.False(t, v.Ignore("root/foo"))
|
||||||
|
assert.True(t, v.Ignore("root_double"))
|
||||||
|
assert.True(t, v.Ignore("ignoredirectory/"))
|
||||||
|
|
||||||
|
// Check current .gitignore
|
||||||
|
assert.True(t, v.Ignore("a.sh"))
|
||||||
|
assert.True(t, v.Ignore("a_double"))
|
||||||
|
assert.False(t, v.Ignore("newfile"))
|
||||||
|
|
||||||
|
// Nested .gitignores should apply in their path.
|
||||||
|
assert.True(t, v.Ignore("b/b.sh"))
|
||||||
|
assert.True(t, v.Ignore("b/whatever/b.sh"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewAInBricksRepo(t *testing.T) {
|
||||||
|
v, err := NewView("./testdata/a")
|
||||||
|
require.NoError(t, err)
|
||||||
|
testViewAtA(t, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewAInTempRepo(t *testing.T) {
|
||||||
|
v, err := NewView(filepath.Join(createFakeRepo(t), "a"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
testViewAtA(t, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewAInTempDir(t *testing.T) {
|
||||||
|
// Since this is not a fake repo it should not traverse up the tree.
|
||||||
|
v, err := NewView(filepath.Join(copyTestdata(t), "a"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Check that this doesn't inherit .gitignore from root.
|
||||||
|
assert.False(t, v.Ignore("root.sh"))
|
||||||
|
assert.False(t, v.Ignore("root/foo"))
|
||||||
|
assert.False(t, v.Ignore("root_double"))
|
||||||
|
|
||||||
|
// Check current .gitignore
|
||||||
|
assert.True(t, v.Ignore("a.sh"))
|
||||||
|
assert.True(t, v.Ignore("a_double"))
|
||||||
|
assert.False(t, v.Ignore("newfile"))
|
||||||
|
|
||||||
|
// Nested .gitignores should apply in their path.
|
||||||
|
assert.True(t, v.Ignore("b/b.sh"))
|
||||||
|
assert.True(t, v.Ignore("b/whatever/b.sh"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testViewAtAB(t *testing.T, v *View) {
|
||||||
|
// Inherit .gitignore from root.
|
||||||
|
assert.True(t, v.Ignore("root.sh"))
|
||||||
|
assert.False(t, v.Ignore("root/foo"))
|
||||||
|
assert.True(t, v.Ignore("root_double"))
|
||||||
|
assert.True(t, v.Ignore("ignoredirectory/"))
|
||||||
|
|
||||||
|
// Inherit .gitignore from root/a.
|
||||||
|
assert.True(t, v.Ignore("a.sh"))
|
||||||
|
assert.True(t, v.Ignore("a_double"))
|
||||||
|
|
||||||
|
// Check current .gitignore
|
||||||
|
assert.True(t, v.Ignore("b.sh"))
|
||||||
|
assert.True(t, v.Ignore("b_double"))
|
||||||
|
assert.False(t, v.Ignore("newfile"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewABInBricksRepo(t *testing.T) {
|
||||||
|
v, err := NewView("./testdata/a/b")
|
||||||
|
require.NoError(t, err)
|
||||||
|
testViewAtAB(t, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewABInTempRepo(t *testing.T) {
|
||||||
|
v, err := NewView(filepath.Join(createFakeRepo(t), "a", "b"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
testViewAtAB(t, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewABInTempDir(t *testing.T) {
|
||||||
|
// Since this is not a fake repo it should not traverse up the tree.
|
||||||
|
v, err := NewView(filepath.Join(copyTestdata(t), "a", "b"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Check that this doesn't inherit .gitignore from root.
|
||||||
|
assert.False(t, v.Ignore("root.sh"))
|
||||||
|
assert.False(t, v.Ignore("root/foo"))
|
||||||
|
assert.False(t, v.Ignore("root_double"))
|
||||||
|
|
||||||
|
// Check that this doesn't inherit .gitignore from root/a.
|
||||||
|
assert.False(t, v.Ignore("a.sh"))
|
||||||
|
assert.False(t, v.Ignore("a_double"))
|
||||||
|
|
||||||
|
// Check current .gitignore
|
||||||
|
assert.True(t, v.Ignore("b.sh"))
|
||||||
|
assert.True(t, v.Ignore("b_double"))
|
||||||
|
assert.False(t, v.Ignore("newfile"))
|
||||||
|
}
|
Loading…
Reference in New Issue