Repository: databricks/cli
Pull request #2429: Do not modify/create .gitignore in bundle root
Author: login: denik
Created: 2025-03-04T16:15:56Z | Merged: 2025-03-05T11:05:30Z

## Changes
- Do not modify or edit .gitignore in bundle root.
- Instead create .databricks/.gitignore with content set to "*"

## Why
Merging our changes into existing .gitignore is complicated and adding .gitignore where it's not expected adds to the noise. Other tools also use the approach in this PR (e.g. ruff creates .ruff_cache/.gitignore).

## Tests
- Modified templates/default-sql to capture this new file.




Diff:
diff --git a/acceptance/bundle/templates/dbt-sql/script b/acceptance/bundle/templates/dbt-sql/script
index 3a2660de5c..427f655a6a 100644
--- a/acceptance/bundle/templates/dbt-sql/script
+++ b/acceptance/bundle/templates/dbt-sql/script
@@ -6,3 +6,4 @@ trace $CLI bundle validate -t prod
 
 # Do not affect this repository's git behaviour #2318
 mv .gitignore out.gitignore
+rm .databricks/.gitignore
diff --git a/acceptance/bundle/templates/default-python/classic/script b/acceptance/bundle/templates/default-python/classic/script
index 7e5524065a..955e059250 100644
--- a/acceptance/bundle/templates/default-python/classic/script
+++ b/acceptance/bundle/templates/default-python/classic/script
@@ -6,6 +6,7 @@ trace $CLI bundle validate -t prod
 
 # Do not affect this repository's git behaviour #2318
 mv .gitignore out.gitignore
+rm .databricks/.gitignore
 
 cd ../../
 
diff --git a/acceptance/bundle/templates/default-python/serverless/script b/acceptance/bundle/templates/default-python/serverless/script
index e5fcb77415..d7b047fec8 100644
--- a/acceptance/bundle/templates/default-python/serverless/script
+++ b/acceptance/bundle/templates/default-python/serverless/script
@@ -6,3 +6,4 @@ trace $CLI bundle validate -t prod
 
 # Do not affect this repository's git behaviour #2318
 mv .gitignore out.gitignore
+rm .databricks/.gitignore
diff --git a/acceptance/bundle/templates/default-sql/output/my_default_sql/.databricks/out.gitignore b/acceptance/bundle/templates/default-sql/output/my_default_sql/.databricks/out.gitignore
new file mode 100644
index 0000000000..72e8ffc0db
--- /dev/null
+++ b/acceptance/bundle/templates/default-sql/output/my_default_sql/.databricks/out.gitignore
@@ -0,0 +1 @@
+*
diff --git a/acceptance/bundle/templates/default-sql/script b/acceptance/bundle/templates/default-sql/script
index 7ea0d863c0..01b393a8cd 100644
--- a/acceptance/bundle/templates/default-sql/script
+++ b/acceptance/bundle/templates/default-sql/script
@@ -6,3 +6,6 @@ trace $CLI bundle validate -t prod
 
 # Do not affect this repository's git behaviour #2318
 mv .gitignore out.gitignore
+
+# Only for this test (default-sql), record .databricks/.gitignore in the output
+mv .databricks/.gitignore .databricks/out.gitignore
diff --git a/acceptance/bundle/templates/experimental-jobs-as-code/script b/acceptance/bundle/templates/experimental-jobs-as-code/script
index 08e48fc5f7..ed9a07dd50 100644
--- a/acceptance/bundle/templates/experimental-jobs-as-code/script
+++ b/acceptance/bundle/templates/experimental-jobs-as-code/script
@@ -11,3 +11,4 @@ rm -fr .venv resources/__pycache__ uv.lock my_jobs_as_code.egg-info
 
 # Do not affect this repository's git behaviour #2318
 mv .gitignore out.gitignore
+rm .databricks/.gitignore
diff --git a/bundle/bundle.go b/bundle/bundle.go
index 9cb8916f51..dad99d6e1a 100644
--- a/bundle/bundle.go
+++ b/bundle/bundle.go
@@ -21,6 +21,7 @@ import (
 	"github.com/databricks/cli/libs/fileset"
 	"github.com/databricks/cli/libs/locker"
 	"github.com/databricks/cli/libs/log"
+	libsync "github.com/databricks/cli/libs/sync"
 	"github.com/databricks/cli/libs/tags"
 	"github.com/databricks/cli/libs/terraform"
 	"github.com/databricks/cli/libs/vfs"
@@ -198,6 +199,7 @@ func (b *Bundle) CacheDir(ctx context.Context, paths ...string) (string, error)
 		return "", err
 	}
 
+	libsync.WriteGitIgnore(ctx, b.BundleRootPath)
 	return dir, nil
 }
 
diff --git a/integration/cmd/sync/sync_test.go b/integration/cmd/sync/sync_test.go
index 88e6ed89ad..337db8fcab 100644
--- a/integration/cmd/sync/sync_test.go
+++ b/integration/cmd/sync/sync_test.go
@@ -224,22 +224,21 @@ func (a *syncTest) snapshotContains(files []string) {
 		_, ok := s.LastModifiedTimes[filePath]
 		assert.True(a.t, ok, "%s not in snapshot file: %v", filePath, s.LastModifiedTimes)
 	}
-	assert.Equal(a.t, len(files), len(s.LastModifiedTimes))
+	assert.Equal(a.t, len(files), len(s.LastModifiedTimes), "files=%s s.LastModifiedTimes=%s", files, s.LastModifiedTimes)
 }
 
 func TestSyncFullFileSync(t *testing.T) {
 	ctx, assertSync := setupSyncTest(t, "--full", "--watch")
 
-	// .gitignore is created by the sync process to enforce .databricks is not synced
 	assertSync.waitForCompletionMarker()
-	assertSync.remoteDirContent(ctx, "", append(repoFiles, ".gitignore"))
+	assertSync.remoteDirContent(ctx, "", repoFiles)
 
 	// New file
 	localFilePath := filepath.Join(assertSync.localRoot, "foo.txt")
 	f := testfile.CreateFile(t, localFilePath)
 	defer f.Close(t)
 	assertSync.waitForCompletionMarker()
-	assertSync.remoteDirContent(ctx, "", append(repoFiles, "foo.txt", ".gitignore"))
+	assertSync.remoteDirContent(ctx, "", append(repoFiles, "foo.txt"))
 	assertSync.remoteFileContent(ctx, "foo.txt", "")
 
 	// Write to file
@@ -255,24 +254,23 @@ func TestSyncFullFileSync(t *testing.T) {
 	// delete
 	f.Remove(t)
 	assertSync.waitForCompletionMarker()
-	assertSync.remoteDirContent(ctx, "", append(repoFiles, ".gitignore"))
+	assertSync.remoteDirContent(ctx, "", repoFiles)
 }
 
 func TestSyncIncrementalFileSync(t *testing.T) {
 	ctx, assertSync := setupSyncTest(t, "--watch")
 
-	// .gitignore is created by the sync process to enforce .databricks is not synced
 	assertSync.waitForCompletionMarker()
-	assertSync.remoteDirContent(ctx, "", append(repoFiles, ".gitignore"))
+	assertSync.remoteDirContent(ctx, "", repoFiles)
 
 	// New file
 	localFilePath := filepath.Join(assertSync.localRoot, "foo.txt")
 	f := testfile.CreateFile(t, localFilePath)
 	defer f.Close(t)
 	assertSync.waitForCompletionMarker()
-	assertSync.remoteDirContent(ctx, "", append(repoFiles, "foo.txt", ".gitignore"))
+	assertSync.remoteDirContent(ctx, "", append(repoFiles, "foo.txt"))
 	assertSync.remoteFileContent(ctx, "foo.txt", "")
-	assertSync.snapshotContains(append(repoFiles, "foo.txt", ".gitignore"))
+	assertSync.snapshotContains(append(repoFiles, "foo.txt"))
 
 	// Write to file
 	f.Overwrite(t, `{"statement": "Mi Gente"}`)
@@ -287,16 +285,15 @@ func TestSyncIncrementalFileSync(t *testing.T) {
 	// delete
 	f.Remove(t)
 	assertSync.waitForCompletionMarker()
-	assertSync.remoteDirContent(ctx, "", append(repoFiles, ".gitignore"))
-	assertSync.snapshotContains(append(repoFiles, ".gitignore"))
+	assertSync.remoteDirContent(ctx, "", repoFiles)
+	assertSync.snapshotContains(repoFiles)
 }
 
 func TestSyncNestedFolderSync(t *testing.T) {
 	ctx, assertSync := setupSyncTest(t, "--watch")
 
-	// .gitignore is created by the sync process to enforce .databricks is not synced
 	assertSync.waitForCompletionMarker()
-	assertSync.remoteDirContent(ctx, "", append(repoFiles, ".gitignore"))
+	assertSync.remoteDirContent(ctx, "", repoFiles)
 
 	// New file
 	localFilePath := filepath.Join(assertSync.localRoot, "dir1/dir2/dir3/foo.txt")
@@ -305,25 +302,24 @@ func TestSyncNestedFolderSync(t *testing.T) {
 	f := testfile.CreateFile(t, localFilePath)
 	defer f.Close(t)
 	assertSync.waitForCompletionMarker()
-	assertSync.remoteDirContent(ctx, "", append(repoFiles, ".gitignore", "dir1"))
+	assertSync.remoteDirContent(ctx, "", append(repoFiles, "dir1"))
 	assertSync.remoteDirContent(ctx, "dir1", []string{"dir2"})
 	assertSync.remoteDirContent(ctx, "dir1/dir2", []string{"dir3"})
 	assertSync.remoteDirContent(ctx, "dir1/dir2/dir3", []string{"foo.txt"})
-	assertSync.snapshotContains(append(repoFiles, ".gitignore", "dir1/dir2/dir3/foo.txt"))
+	assertSync.snapshotContains(append(repoFiles, "dir1/dir2/dir3/foo.txt"))
 
 	// delete
 	f.Remove(t)
 	assertSync.waitForCompletionMarker()
 	assertSync.remoteNotExist(ctx, "dir1")
-	assertSync.snapshotContains(append(repoFiles, ".gitignore"))
+	assertSync.snapshotContains(repoFiles)
 }
 
 func TestSyncNestedFolderDoesntFailOnNonEmptyDirectory(t *testing.T) {
 	ctx, assertSync := setupSyncTest(t, "--watch")
 
-	// .gitignore is created by the sync process to enforce .databricks is not synced
 	assertSync.waitForCompletionMarker()
-	assertSync.remoteDirContent(ctx, "", append(repoFiles, ".gitignore"))
+	assertSync.remoteDirContent(ctx, "", repoFiles)
 
 	// New file
 	localFilePath := filepath.Join(assertSync.localRoot, "dir1/dir2/dir3/foo.txt")
@@ -353,9 +349,8 @@ func TestSyncNestedFolderDoesntFailOnNonEmptyDirectory(t *testing.T) {
 func TestSyncNestedSpacePlusAndHashAreEscapedSync(t *testing.T) {
 	ctx, assertSync := setupSyncTest(t, "--watch")
 
-	// .gitignore is created by the sync process to enforce .databricks is not synced
 	assertSync.waitForCompletionMarker()
-	assertSync.remoteDirContent(ctx, "", append(repoFiles, ".gitignore"))
+	assertSync.remoteDirContent(ctx, "", repoFiles)
 
 	// New file
 	localFilePath := filepath.Join(assertSync.localRoot, "dir1/a b+c/c+d e/e+f g#i.txt")
@@ -364,17 +359,17 @@ func TestSyncNestedSpacePlusAndHashAreEscapedSync(t *testing.T) {
 	f := testfile.CreateFile(t, localFilePath)
 	defer f.Close(t)
 	assertSync.waitForCompletionMarker()
-	assertSync.remoteDirContent(ctx, "", append(repoFiles, ".gitignore", "dir1"))
+	assertSync.remoteDirContent(ctx, "", append(repoFiles, "dir1"))
 	assertSync.remoteDirContent(ctx, "dir1", []string{"a b+c"})
 	assertSync.remoteDirContent(ctx, "dir1/a b+c", []string{"c+d e"})
 	assertSync.remoteDirContent(ctx, "dir1/a b+c/c+d e", []string{"e+f g#i.txt"})
-	assertSync.snapshotContains(append(repoFiles, ".gitignore", "dir1/a b+c/c+d e/e+f g#i.txt"))
+	assertSync.snapshotContains(append(repoFiles, "dir1/a b+c/c+d e/e+f g#i.txt"))
 
 	// delete
 	f.Remove(t)
 	assertSync.waitForCompletionMarker()
 	assertSync.remoteNotExist(ctx, "dir1/a b+c/c+d e")
-	assertSync.snapshotContains(append(repoFiles, ".gitignore"))
+	assertSync.snapshotContains(repoFiles)
 }
 
 // This is a check for the edge case when a user does the following:
@@ -395,23 +390,23 @@ func TestSyncIncrementalFileOverwritesFolder(t *testing.T) {
 	f := testfile.CreateFile(t, localFilePath)
 	defer f.Close(t)
 	assertSync.waitForCompletionMarker()
-	assertSync.remoteDirContent(ctx, "", append(repoFiles, ".gitignore", "foo"))
+	assertSync.remoteDirContent(ctx, "", append(repoFiles, "foo"))
 	assertSync.remoteDirContent(ctx, "foo", []string{"bar.txt"})
-	assertSync.snapshotContains(append(repoFiles, ".gitignore", "foo/bar.txt"))
+	assertSync.snapshotContains(append(repoFiles, "foo/bar.txt"))
 
 	// delete foo/bar.txt
 	f.Remove(t)
 	os.Remove(filepath.Join(assertSync.localRoot, "foo"))
 	assertSync.waitForCompletionMarker()
 	assertSync.remoteNotExist(ctx, "foo")
-	assertSync.snapshotContains(append(repoFiles, ".gitignore"))
+	assertSync.snapshotContains(repoFiles)
 
 	f2 := testfile.CreateFile(t, filepath.Join(assertSync.localRoot, "foo"))
 	defer f2.Close(t)
 	assertSync.waitForCompletionMarker()
-	assertSync.remoteDirContent(ctx, "", append(repoFiles, ".gitignore", "foo"))
+	assertSync.remoteDirContent(ctx, "", append(repoFiles, "foo"))
 	assertSync.objectType(ctx, "foo", "FILE")
-	assertSync.snapshotContains(append(repoFiles, ".gitignore", "foo"))
+	assertSync.snapshotContains(append(repoFiles, "foo"))
 }
 
 func TestSyncIncrementalSyncPythonNotebookToFile(t *testing.T) {
@@ -425,23 +420,23 @@ func TestSyncIncrementalSyncPythonNotebookToFile(t *testing.T) {
 
 	// notebook was uploaded properly
 	assertSync.waitForCompletionMarker()
-	assertSync.remoteDirContent(ctx, "", append(repoFiles, ".gitignore", "foo"))
+	assertSync.remoteDirContent(ctx, "", append(repoFiles, "foo"))
 	assertSync.objectType(ctx, "foo", "NOTEBOOK")
 	assertSync.language(ctx, "foo", "PYTHON")
-	assertSync.snapshotContains(append(repoFiles, ".gitignore", "foo.py"))
+	assertSync.snapshotContains(append(repoFiles, "foo.py"))
 
 	// convert to vanilla python file
 	f.Overwrite(t, "# No longer a python notebook")
 	assertSync.waitForCompletionMarker()
 	assertSync.objectType(ctx, "foo.py", "FILE")
-	assertSync.remoteDirContent(ctx, "", append(repoFiles, ".gitignore", "foo.py"))
-	assertSync.snapshotContains(append(repoFiles, ".gitignore", "foo.py"))
+	assertSync.remoteDirContent(ctx, "", append(repoFiles, "foo.py"))
+	assertSync.snapshotContains(append(repoFiles, "foo.py"))
 
 	// delete the vanilla python file
 	f.Remove(t)
 	assertSync.waitForCompletionMarker()
-	assertSync.remoteDirContent(ctx, "", append(repoFiles, ".gitignore"))
-	assertSync.snapshotContains(append(repoFiles, ".gitignore"))
+	assertSync.remoteDirContent(ctx, "", repoFiles)
+	assertSync.snapshotContains(repoFiles)
 }
 
 func TestSyncIncrementalSyncFileToPythonNotebook(t *testing.T) {
@@ -454,17 +449,17 @@ func TestSyncIncrementalSyncFileToPythonNotebook(t *testing.T) {
 	assertSync.waitForCompletionMarker()
 
 	// assert file upload
-	assertSync.remoteDirContent(ctx, "", append(repoFiles, ".gitignore", "foo.py"))
+	assertSync.remoteDirContent(ctx, "", append(repoFiles, "foo.py"))
 	assertSync.objectType(ctx, "foo.py", "FILE")
-	assertSync.snapshotContains(append(repoFiles, ".gitignore", "foo.py"))
+	assertSync.snapshotContains(append(repoFiles, "foo.py"))
 
 	// convert to notebook
 	f.Overwrite(t, "# Databricks notebook source")
 	assertSync.waitForCompletionMarker()
 	assertSync.objectType(ctx, "foo", "NOTEBOOK")
 	assertSync.language(ctx, "foo", "PYTHON")
-	assertSync.remoteDirContent(ctx, "", append(repoFiles, ".gitignore", "foo"))
-	assertSync.snapshotContains(append(repoFiles, ".gitignore", "foo.py"))
+	assertSync.remoteDirContent(ctx, "", append(repoFiles, "foo"))
+	assertSync.snapshotContains(append(repoFiles, "foo.py"))
 }
 
 func TestSyncIncrementalSyncPythonNotebookDelete(t *testing.T) {
@@ -478,14 +473,14 @@ func TestSyncIncrementalSyncPythonNotebookDelete(t *testing.T) {
 	assertSync.waitForCompletionMarker()
 
 	// notebook was uploaded properly
-	assertSync.remoteDirContent(ctx, "", append(repoFiles, ".gitignore", "foo"))
+	assertSync.remoteDirContent(ctx, "", append(repoFiles, "foo"))
 	assertSync.objectType(ctx, "foo", "NOTEBOOK")
 	assertSync.language(ctx, "foo", "PYTHON")
 
 	// Delete notebook
 	f.Remove(t)
 	assertSync.waitForCompletionMarker()
-	assertSync.remoteDirContent(ctx, "", append(repoFiles, ".gitignore"))
+	assertSync.remoteDirContent(ctx, "", repoFiles)
 }
 
 func TestSyncEnsureRemotePathIsUsableIfRepoDoesntExist(t *testing.T) {
diff --git a/libs/git/fileset.go b/libs/git/fileset.go
index 8391548c9c..7c0c372c95 100644
--- a/libs/git/fileset.go
+++ b/libs/git/fileset.go
@@ -43,7 +43,3 @@ func (f *FileSet) Files() ([]fileset.File, error) {
 	f.view.repo.taintIgnoreRules()
 	return f.fileset.Files()
 }
-
-func (f *FileSet) EnsureValidGitIgnoreExists() error {
-	return f.view.EnsureValidGitIgnoreExists()
-}
diff --git a/libs/git/fileset_test.go b/libs/git/fileset_test.go
index 6d239edf59..cd85ee810d 100644
--- a/libs/git/fileset_test.go
+++ b/libs/git/fileset_test.go
@@ -1,10 +1,8 @@
 package git
 
 import (
-	"os"
 	"path"
 	"path/filepath"
-	"strings"
 	"testing"
 
 	"github.com/databricks/cli/libs/vfs"
@@ -51,34 +49,3 @@ func TestFileSetNonCleanRoot(t *testing.T) {
 	require.NoError(t, err)
 	assert.Len(t, files, 3)
 }
-
-func TestFileSetAddsCacheDirToGitIgnore(t *testing.T) {
-	projectDir := t.TempDir()
-	fileSet, err := NewFileSetAtRoot(vfs.MustNew(projectDir))
-	require.NoError(t, err)
-	err = fileSet.EnsureValidGitIgnoreExists()
-	require.NoError(t, err)
-
-	gitIgnorePath := filepath.Join(projectDir, ".gitignore")
-	assert.FileExists(t, gitIgnorePath)
-	fileBytes, err := os.ReadFile(gitIgnorePath)
-	assert.NoError(t, err)
-	assert.Contains(t, string(fileBytes), ".databricks")
-}
-
-func TestFileSetDoesNotCacheDirToGitIgnoreIfAlreadyPresent(t *testing.T) {
-	projectDir := t.TempDir()
-	gitIgnorePath := filepath.Join(projectDir, ".gitignore")
-
-	fileSet, err := NewFileSetAtRoot(vfs.MustNew(projectDir))
-	require.NoError(t, err)
-	err = os.WriteFile(gitIgnorePath, []byte(".databricks"), 0o644)
-	require.NoError(t, err)
-
-	err = fileSet.EnsureValidGitIgnoreExists()
-	require.NoError(t, err)
-
-	b, err := os.ReadFile(gitIgnorePath)
-	require.NoError(t, err)
-	assert.Equal(t, 1, strings.Count(string(b), ".databricks"))
-}
diff --git a/libs/git/view.go b/libs/git/view.go
index db22dfc5d4..142cc49479 100644
--- a/libs/git/view.go
+++ b/libs/git/view.go
@@ -90,46 +90,25 @@ func NewView(worktreeRoot, root vfs.Path) (*View, error) {
 	target = strings.TrimPrefix(target, string(os.PathSeparator))
 	target = path.Clean(filepath.ToSlash(target))
 
-	return &View{
+	result := &View{
 		repo:       repo,
 		targetPath: target,
-	}, nil
+	}
+
+	result.SetupDefaults()
+	return result, nil
 }
 
 func NewViewAtRoot(root vfs.Path) (*View, error) {
 	return NewView(root, root)
 }
 
-func (v *View) EnsureValidGitIgnoreExists() error {
-	ign, err := v.IgnoreDirectory(".databricks")
-	if err != nil {
-		return err
-	}
-
-	// return early if .databricks is already being ignored
-	if ign {
-		return nil
-	}
-
-	// Create .gitignore with .databricks entry
-	gitIgnorePath := filepath.Join(v.repo.Root(), v.targetPath, ".gitignore")
-	file, err := os.OpenFile(gitIgnorePath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644)
-	if err != nil {
-		return err
-	}
-	defer file.Close()
-
+func (v *View) SetupDefaults() {
 	// Hard code .databricks ignore pattern so that we never sync it (irrespective)
 	// of .gitignore patterns
 	v.repo.addIgnoreRule(newStringIgnoreRules([]string{
 		".databricks",
 	}))
 
-	_, err = file.WriteString("\n.databricks\n")
-	if err != nil {
-		return err
-	}
-
 	v.repo.taintIgnoreRules()
-	return nil
 }
diff --git a/libs/git/view_test.go b/libs/git/view_test.go
index 96881fdeee..9d7a4cdecb 100644
--- a/libs/git/view_test.go
+++ b/libs/git/view_test.go
@@ -209,100 +209,12 @@ func TestViewABInTempDir(t *testing.T) {
 	assert.False(t, tv.Ignore("newfile"))
 }
 
-func TestViewDoesNotChangeGitignoreIfCacheDirAlreadyIgnoredAtRoot(t *testing.T) {
-	expected, err := os.ReadFile("./testdata_view_ignore/.gitignore")
-	require.NoError(t, err)
-
-	repoPath := createFakeRepo(t, "testdata_view_ignore")
-
-	// Since root .gitignore already has .databricks, there should be no edits
-	// to root .gitignore
-	v, err := NewViewAtRoot(vfs.MustNew(repoPath))
-	require.NoError(t, err)
-
-	err = v.EnsureValidGitIgnoreExists()
-	require.NoError(t, err)
-
-	actual, err := os.ReadFile(filepath.Join(repoPath, ".gitignore"))
-	require.NoError(t, err)
-
-	assert.Equal(t, string(expected), string(actual))
-}
-
-func TestViewDoesNotChangeGitignoreIfCacheDirAlreadyIgnoredInSubdir(t *testing.T) {
-	expected, err := os.ReadFile("./testdata_view_ignore/a/.gitignore")
-	require.NoError(t, err)
-
-	repoPath := createFakeRepo(t, "testdata_view_ignore")
-
-	// Since root .gitignore already has .databricks, there should be no edits
-	// to a/.gitignore
-	v, err := NewView(vfs.MustNew(repoPath), vfs.MustNew(filepath.Join(repoPath, "a")))
-	require.NoError(t, err)
-
-	err = v.EnsureValidGitIgnoreExists()
-	require.NoError(t, err)
-
-	actual, err := os.ReadFile(filepath.Join(repoPath, v.targetPath, ".gitignore"))
-	require.NoError(t, err)
-
-	assert.Equal(t, string(expected), string(actual))
-}
-
-func TestViewAddsGitignoreWithCacheDir(t *testing.T) {
-	repoPath := createFakeRepo(t, "testdata")
-	err := os.Remove(filepath.Join(repoPath, ".gitignore"))
-	assert.NoError(t, err)
-
-	// Since root .gitignore was deleted, new view adds .databricks to root .gitignore
-	v, err := NewViewAtRoot(vfs.MustNew(repoPath))
-	require.NoError(t, err)
-
-	err = v.EnsureValidGitIgnoreExists()
-	require.NoError(t, err)
-
-	actual, err := os.ReadFile(filepath.Join(repoPath, ".gitignore"))
-	require.NoError(t, err)
-
-	assert.Contains(t, string(actual), "\n.databricks\n")
-}
-
-func TestViewAddsGitignoreWithCacheDirAtSubdir(t *testing.T) {
-	repoPath := createFakeRepo(t, "testdata")
-	err := os.Remove(filepath.Join(repoPath, ".gitignore"))
-	require.NoError(t, err)
-
-	// Since root .gitignore was deleted, new view adds .databricks to a/.gitignore
-	v, err := NewView(vfs.MustNew(repoPath), vfs.MustNew(filepath.Join(repoPath, "a")))
-	require.NoError(t, err)
-
-	err = v.EnsureValidGitIgnoreExists()
-	require.NoError(t, err)
-
-	actual, err := os.ReadFile(filepath.Join(repoPath, v.targetPath, ".gitignore"))
-	require.NoError(t, err)
-
-	// created .gitignore has cache dir listed
-	assert.Contains(t, string(actual), "\n.databricks\n")
-	assert.NoFileExists(t, filepath.Join(repoPath, ".gitignore"))
-}
-
 func TestViewAlwaysIgnoresCacheDir(t *testing.T) {
 	repoPath := createFakeRepo(t, "testdata")
 
 	v, err := NewViewAtRoot(vfs.MustNew(repoPath))
 	require.NoError(t, err)
 
-	err = v.EnsureValidGitIgnoreExists()
-	require.NoError(t, err)
-
-	// Delete root .gitignore which contains .databricks entry
-	err = os.Remove(filepath.Join(repoPath, ".gitignore"))
-	require.NoError(t, err)
-
-	// taint rules to reload .gitignore
-	v.repo.taintIgnoreRules()
-
 	// assert .databricks is still being ignored
 	ign1, err := v.IgnoreDirectory(".databricks")
 	require.NoError(t, err)
diff --git a/libs/sync/gitignore.go b/libs/sync/gitignore.go
new file mode 100644
index 0000000000..b3888a0cce
--- /dev/null
+++ b/libs/sync/gitignore.go
@@ -0,0 +1,26 @@
+package sync
+
+import (
+	"context"
+	"os"
+	"path/filepath"
+
+	"github.com/databricks/cli/libs/log"
+)
+
+func WriteGitIgnore(ctx context.Context, dir string) {
+	gitignorePath := filepath.Join(dir, ".databricks", ".gitignore")
+	file, err := os.OpenFile(gitignorePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
+	if err != nil {
+		if os.IsExist(err) {
+			return
+		}
+		log.Debugf(ctx, "Failed to create %s: %s", gitignorePath, err)
+	}
+
+	defer file.Close()
+	_, err = file.WriteString("*\n")
+	if err != nil {
+		log.Debugf(ctx, "Error writing to %s: %s", gitignorePath, err)
+	}
+}
diff --git a/libs/sync/sync.go b/libs/sync/sync.go
index f13fa934ad..4d14f745a9 100644
--- a/libs/sync/sync.go
+++ b/libs/sync/sync.go
@@ -69,10 +69,7 @@ func New(ctx context.Context, opts SyncOptions) (*Sync, error) {
 		return nil, err
 	}
 
-	err = fileSet.EnsureValidGitIgnoreExists()
-	if err != nil {
-		return nil, err
-	}
+	WriteGitIgnore(ctx, opts.LocalRoot.Native())
 
 	includeFileSet, err := fileset.NewGlobSet(opts.LocalRoot, opts.Include)
 	if err != nil {
diff --git a/libs/sync/sync_test.go b/libs/sync/sync_test.go
index f30431770a..1b54982754 100644
--- a/libs/sync/sync_test.go
+++ b/libs/sync/sync_test.go
@@ -40,9 +40,6 @@ func TestGetFileSet(t *testing.T) {
 	fileSet, err := git.NewFileSetAtRoot(root)
 	require.NoError(t, err)
 
-	err = fileSet.EnsureValidGitIgnoreExists()
-	require.NoError(t, err)
-
 	inc, err := fileset.NewGlobSet(root, []string{})
 	require.NoError(t, err)
 
@@ -59,7 +56,7 @@ func TestGetFileSet(t *testing.T) {
 
 	fileList, err := s.GetFileList(ctx)
 	require.NoError(t, err)
-	require.Len(t, fileList, 10)
+	require.Len(t, fileList, 9)
 
 	inc, err = fileset.NewGlobSet(root, []string{})
 	require.NoError(t, err)
@@ -77,7 +74,7 @@ func TestGetFileSet(t *testing.T) {
 
 	fileList, err = s.GetFileList(ctx)
 	require.NoError(t, err)
-	require.Len(t, fileList, 2)
+	require.Len(t, fileList, 1)
 
 	inc, err = fileset.NewGlobSet(root, []string{"./.databricks/*.go"})
 	require.NoError(t, err)
@@ -95,7 +92,7 @@ func TestGetFileSet(t *testing.T) {
 
 	fileList, err = s.GetFileList(ctx)
 	require.NoError(t, err)
-	require.Len(t, fileList, 11)
+	require.Len(t, fileList, 10)
 }
 
 func TestRecursiveExclude(t *testing.T) {
@@ -106,9 +103,6 @@ func TestRecursiveExclude(t *testing.T) {
 	fileSet, err := git.NewFileSetAtRoot(root)
 	require.NoError(t, err)
 
-	err = fileSet.EnsureValidGitIgnoreExists()
-	require.NoError(t, err)
-
 	inc, err := fileset.NewGlobSet(root, []string{})
 	require.NoError(t, err)
 
@@ -125,7 +119,7 @@ func TestRecursiveExclude(t *testing.T) {
 
 	fileList, err := s.GetFileList(ctx)
 	require.NoError(t, err)
-	require.Len(t, fileList, 7)
+	require.Len(t, fileList, 6)
 }
 
 func TestNegateExclude(t *testing.T) {
@@ -136,9 +130,6 @@ func TestNegateExclude(t *testing.T) {
 	fileSet, err := git.NewFileSetAtRoot(root)
 	require.NoError(t, err)
 
-	err = fileSet.EnsureValidGitIgnoreExists()
-	require.NoError(t, err)
-
 	inc, err := fileset.NewGlobSet(root, []string{})
 	require.NoError(t, err)
 


Reviews:
user: login: pietern
body: Can you check that the file `.databricks/.gitignore` itself is also excluded from Git commits in this new setup? I recall some tools don't match dotfiles with the pattern `*`.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T09:57:57Z
user: login: denik
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T10:07:24Z
user: login: denik
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T10:09:03Z
user: login: pietern
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T10:25:59Z
user: login: denik
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T10:30:32Z
user: login: denik
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T10:30:40Z
user: login: denik
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T10:31:06Z
user: login: pietern
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T10:32:24Z

Review comments:
diff_hunk: @@ -6,3 +6,4 @@ trace $CLI bundle validate -t prod
 
 # Do not affect this repository's git behaviour #2318
 mv .gitignore out.gitignore
+rm .databricks/.gitignore
path: acceptance/bundle/templates/dbt-sql/script
user: login: pietern
body: Why not `rm -fr .databricks` like below?
created_at: 2025-03-05T09:51:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,3 +6,4 @@ trace $CLI bundle validate -t prod
 
 # Do not affect this repository's git behaviour #2318
 mv .gitignore out.gitignore
+mv .databricks/.gitignore .databricks/out.gitignore
path: acceptance/bundle/templates/default-sql/script
user: login: pietern
body: I don't understand the nuance -- do you want to get coverage for `.databricks` being excluded exclusively for this template?
created_at: 2025-03-05T09:52:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,3 +6,4 @@ trace $CLI bundle validate -t prod
 
 # Do not affect this repository's git behaviour #2318
 mv .gitignore out.gitignore
+mv .databricks/.gitignore .databricks/out.gitignore
path: acceptance/bundle/templates/default-sql/script
user: login: denik
body: This records .databricks/.gitignore so we can see what is this. It's the same every time, so no need to record in all tests. default-sql is selected randomly.
created_at: 2025-03-05T10:07:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,3 +6,4 @@ trace $CLI bundle validate -t prod
 
 # Do not affect this repository's git behaviour #2318
 mv .gitignore out.gitignore
+rm .databricks/.gitignore
path: acceptance/bundle/templates/dbt-sql/script
user: login: denik
body: No particular reason. There is nothing else at this point in .databricks and this command highlights that. Once we add deploy, we'd need to process full .databricks.
created_at: 2025-03-05T10:09:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,3 +6,4 @@ trace $CLI bundle validate -t prod
 
 # Do not affect this repository's git behaviour #2318
 mv .gitignore out.gitignore
+mv .databricks/.gitignore .databricks/out.gitignore
path: acceptance/bundle/templates/default-sql/script
user: login: pietern
body: Could you add a comment highlighting this, so it is clear it doesn't need to be copy-pasted?
created_at: 2025-03-05T10:24:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,6 +6,7 @@ trace $CLI bundle validate -t prod
 
 # Do not affect this repository's git behaviour #2318
 mv .gitignore out.gitignore
+rm -fr .databricks
path: acceptance/bundle/templates/default-python/classic/script
user: login: pietern
body: For consistency this should also only remove the `.gitignore` file.

The different approaches in different templates is fine, but if they diverge it should include a comment why it is done so we remain intentional about every bit of behavior in these tests.
created_at: 2025-03-05T10:25:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,3 +6,4 @@ trace $CLI bundle validate -t prod
 
 # Do not affect this repository's git behaviour #2318
 mv .gitignore out.gitignore
+rm .databricks/.gitignore
path: acceptance/bundle/templates/dbt-sql/script
user: login: denik
body: Updated to use specific rm c2536d542f47e9ee3b0be0fb7ea08a24501c47b0
created_at: 2025-03-05T10:30:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,3 +6,4 @@ trace $CLI bundle validate -t prod
 
 # Do not affect this repository's git behaviour #2318
 mv .gitignore out.gitignore
+mv .databricks/.gitignore .databricks/out.gitignore
path: acceptance/bundle/templates/default-sql/script
user: login: denik
body: Added c2536d542f47e9ee3b0be0fb7ea08a24501c47b0
created_at: 2025-03-05T10:30:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,6 +6,7 @@ trace $CLI bundle validate -t prod
 
 # Do not affect this repository's git behaviour #2318
 mv .gitignore out.gitignore
+rm -fr .databricks
path: acceptance/bundle/templates/default-python/classic/script
user: login: denik
body: done in c2536d542f47e9ee3b0be0fb7ea08a24501c47b0
created_at: 2025-03-05T10:31:05Z
author_association: CONTRIBUTOR

PR comments:
user: login: denik
created_at: 2025-03-05T10:10:42Z
body: > Can you check that the file `.databricks/.gitignore` itself is also excluded from Git commits in this new setup? I recall some tools don't match dotfiles with the pattern `*`.

```
/tmp % mkdir newrepo
/tmp % cd newrepo
/tmp/newrepo % git init
Initialized empty Git repository in /private/tmp/newrepo/.git/
/tmp/newrepo % mkdir .databricks
/tmp/newrepo % printf "*\n" > .databricks/.gitignore
/tmp/newrepo % touch .databricks/other_file
/tmp/newrepo % touch hello
/tmp/newrepo % mkdir .databricks/subdir
/tmp/newrepo % touch .databricks/subdir/file.txt
/tmp/newrepo % git status
On branch main

No commits yet

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        hello

nothing added to commit but untracked files present (use "git add" to track)
```
author_association: CONTRIBUTOR
user: login: pietern
created_at: 2025-03-05T10:26:09Z
body: Thanks for confirming!
author_association: CONTRIBUTOR