From f327a8d3193d49d11fc3353a19e0d05aa9f8594c Mon Sep 17 00:00:00 2001 From: dimk0 <4886215+dimk0@users.noreply.github.com> Date: Sat, 14 Nov 2026 23:42:26 +0000 Subject: [PATCH 1/5] air: reject code_source include_paths entries that match no files For a working-tree snapshot, git ls-files silently ignores a pathspec that matches nothing, so a mistyped or stale include_paths entry was left out of the snapshot. The run then failed on the node after compute was provisioned. Commit-pinned snapshots already reject missing include_paths; working-tree snapshots now do the same before packaging. Co-authored-by: Isaac --- cmd/air/snapshot_package.go | 31 +++++++++++++++++++++++++ cmd/air/snapshot_package_test.go | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/cmd/air/snapshot_package.go b/cmd/air/snapshot_package.go index c1dc9c5f89d..174c4b17806 100644 --- a/cmd/air/snapshot_package.go +++ b/cmd/air/snapshot_package.go @@ -8,6 +8,7 @@ import ( "io" "os" "os/exec" + "path" "path/filepath" "strings" @@ -193,9 +194,39 @@ func snapshotFiles(ctx context.Context, repoPath string, includePaths []string, if len(files) == 0 { return nil, emptySnapshotError(ctx, repoPath, includePaths, isGitRepo) } + if unmatched := unmatchedIncludePaths(includePaths, files); len(unmatched) > 0 { + return nil, fmt.Errorf("code_source include_paths select no files under %s: %s. Check that each path exists under root_path and is not ignored by git (`git check-ignore -v `)", filepath.ToSlash(repoPath), strings.Join(unmatched, ", ")) + } return files, nil } +// unmatchedIncludePaths returns the include_paths entries that select none of files. +// git ls-files ignores a pathspec that matches nothing, so a mistyped entry would be +// left out of the snapshot and the run would fail on the node after compute was +// provisioned. Entries with glob characters are skipped because git matches them as +// patterns rather than as path prefixes. +func unmatchedIncludePaths(includePaths []string, files []snapshotFile) []string { + var unmatched []string + for _, p := range includePaths { + if strings.ContainsAny(p, "*?[") { + continue + } + prefix := path.Clean(filepath.ToSlash(p)) + matched := prefix == "." + for _, file := range files { + rel := filepath.ToSlash(file.rel) + if rel == prefix || strings.HasPrefix(rel, prefix+"/") { + matched = true + break + } + } + if !matched { + unmatched = append(unmatched, p) + } + } + return unmatched +} + // emptySnapshotError explains why no file was selected for the snapshot. An empty // tarball is accepted by the upload but fails on the node, after compute was // provisioned, so it is rejected here with the most likely cause. diff --git a/cmd/air/snapshot_package_test.go b/cmd/air/snapshot_package_test.go index 068997e2671..cad2a6a5bba 100644 --- a/cmd/air/snapshot_package_test.go +++ b/cmd/air/snapshot_package_test.go @@ -324,3 +324,42 @@ func TestSnapshotFiles_RejectsIncludePathsMatchingNothing(t *testing.T) { assert.Contains(t, err.Error(), "contains no files") assert.Contains(t, err.Error(), "include_paths") } + +func TestSnapshotFiles_RejectsIncludePathMatchingNothingAlongsideOthers(t *testing.T) { + repo := t.TempDir() + writeRepoFile(t, repo, "ray_bootstrap.sh", "#!/bin/bash\n") + writeRepoFile(t, repo, "my-job/main.py", "print(1)\n") + + _, err := snapshotFiles(t.Context(), repo, []string{"ray_bootstrap.sh", "my_job"}, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "include_paths select no files") + assert.Contains(t, err.Error(), ": my_job.") + assert.NotContains(t, err.Error(), "ray_bootstrap.sh") +} + +func TestSnapshotFiles_RejectsIncludePathWhoseFilesAreIgnored(t *testing.T) { + repo := newTestRepo(t) + writeRepoFile(t, repo, ".gitignore", "build/\n") + writeRepoFile(t, repo, "main.py", "print(1)\n") + writeRepoFile(t, repo, "build/out.bin", "x") + + _, err := snapshotFiles(t.Context(), repo, []string{"main.py", "build"}, true) + require.Error(t, err) + assert.Contains(t, err.Error(), ": build.") + assert.Contains(t, err.Error(), "check-ignore -v") +} + +func TestSnapshotFiles_AcceptsIncludePathForms(t *testing.T) { + repo := t.TempDir() + writeRepoFile(t, repo, "src/model.py", "print()") + writeRepoFile(t, repo, "README.md", "readme") + writeRepoFile(t, repo, "notes.txt", "notes") + + files, err := snapshotFiles(t.Context(), repo, []string{"./src/", "README.md", "*.txt"}, false) + require.NoError(t, err) + var rels []string + for _, file := range files { + rels = append(rels, filepath.ToSlash(file.rel)) + } + assert.ElementsMatch(t, []string{"src/model.py", "README.md", "notes.txt"}, rels) +} From 68db1468b41d3bfae73f8eb5fa5c08193d3375d1 Mon Sep 17 00:00:00 2001 From: dimk0 <4886215+dimk0@users.noreply.github.com> Date: Sun, 15 Nov 2026 00:03:18 +0000 Subject: [PATCH 2/5] Add changelog entry Co-authored-by: Isaac --- .nextchanges/air/7639.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .nextchanges/air/7639.md diff --git a/.nextchanges/air/7639.md b/.nextchanges/air/7639.md new file mode 100644 index 00000000000..6649b534fdd --- /dev/null +++ b/.nextchanges/air/7639.md @@ -0,0 +1 @@ +* `air run` now rejects a `code_source.include_paths` entry that matches no files instead of uploading a snapshot without it. ([#7639](https://github.com/databricks/cli/pull/7639)) From 07a6a9c503442112522c9dd62aa67913fe8dbb97 Mon Sep 17 00:00:00 2001 From: dimk0 <4886215+dimk0@users.noreply.github.com> Date: Sun, 15 Nov 2026 06:17:18 +0000 Subject: [PATCH 3/5] air: leave pathspec patterns to git and count only files that get packed - Skip the unmatched-entry check for pathspec magic (":!x", ":(exclude)x", ":/x") and backslash escapes, as for globs. These are patterns, and an exclusion can never select a file, so the check rejected valid configs. - Drop entries that the tarball writer does not pack (directories such as a nested git repository or a submodule) from the listing, so the empty and unmatched checks see what is uploaded. - Reject listed files outside root_path, which pathspec magic such as ":(top)x" can select. - Reword the error: it lists only the unmatched entries, and empty directories, nested repositories and submodules add no files. - Cover the "." shortcut, the path-component boundary, globs, exclusions, nested repositories and the containment check in tests. Co-authored-by: Isaac --- cmd/air/snapshot_package.go | 30 ++++++++++---- cmd/air/snapshot_package_test.go | 69 ++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/cmd/air/snapshot_package.go b/cmd/air/snapshot_package.go index 174c4b17806..a7aebcd91f0 100644 --- a/cmd/air/snapshot_package.go +++ b/cmd/air/snapshot_package.go @@ -169,6 +169,10 @@ func snapshotFiles(ctx context.Context, repoPath string, includePaths []string, if name == ".git" || strings.HasPrefix(name, ".git/") || strings.HasPrefix(base, "._") { continue } + // Pathspec magic such as ":(top)x" or ":/x" can select files above root_path. + if !filepath.IsLocal(filepath.FromSlash(name)) { + return nil, fmt.Errorf("code_source include_paths select %q, which is outside root_path %s", name, filepath.ToSlash(repoPath)) + } info, err := os.Lstat(filepath.Join(repoPath, filepath.FromSlash(name))) if errors.Is(err, os.ErrNotExist) { continue @@ -176,6 +180,12 @@ func snapshotFiles(ctx context.Context, repoPath string, includePaths []string, if err != nil { return nil, fmt.Errorf("failed to inspect snapshot path %q: %w", name, err) } + // git lists a nested repository or a submodule as a directory, which tarpack + // does not pack; keep the listing to what lands in the archive so the empty + // and include_paths checks below see the same thing. + if !info.Mode().IsRegular() && info.Mode()&os.ModeSymlink == 0 { + continue + } linkTarget := "" if info.Mode()&os.ModeSymlink != 0 { linkTarget, err = os.Readlink(filepath.Join(repoPath, filepath.FromSlash(name))) @@ -195,20 +205,19 @@ func snapshotFiles(ctx context.Context, repoPath string, includePaths []string, return nil, emptySnapshotError(ctx, repoPath, includePaths, isGitRepo) } if unmatched := unmatchedIncludePaths(includePaths, files); len(unmatched) > 0 { - return nil, fmt.Errorf("code_source include_paths select no files under %s: %s. Check that each path exists under root_path and is not ignored by git (`git check-ignore -v `)", filepath.ToSlash(repoPath), strings.Join(unmatched, ", ")) + return nil, fmt.Errorf("code_source include_paths select no files under %s: %s. Check that these paths exist under root_path and hold files git does not ignore (`git check-ignore -v ` shows the rule for a file); empty directories, nested git repositories and submodules add no files", filepath.ToSlash(repoPath), strings.Join(unmatched, ", ")) } return files, nil } -// unmatchedIncludePaths returns the include_paths entries that select none of files. -// git ls-files ignores a pathspec that matches nothing, so a mistyped entry would be -// left out of the snapshot and the run would fail on the node after compute was -// provisioned. Entries with glob characters are skipped because git matches them as -// patterns rather than as path prefixes. +// unmatchedIncludePaths returns the plain-path include_paths entries that select none +// of files. git ls-files drops a pathspec that matches nothing, so a mistyped entry +// would silently be missing from the snapshot. Entries git reads as patterns are left +// to git, since a path-prefix comparison cannot evaluate them. func unmatchedIncludePaths(includePaths []string, files []snapshotFile) []string { var unmatched []string for _, p := range includePaths { - if strings.ContainsAny(p, "*?[") { + if isPathspecPattern(p) { continue } prefix := path.Clean(filepath.ToSlash(p)) @@ -227,6 +236,13 @@ func unmatchedIncludePaths(includePaths []string, files []snapshotFile) []string return unmatched } +// isPathspecPattern reports whether git matches p as a pattern rather than as a +// literal path: pathspec magic (":!x", ":(exclude)x", ":/x", ...) or a glob. git +// treats a backslash as an escape; on Windows ToSlash turns it into a separator first. +func isPathspecPattern(p string) bool { + return strings.HasPrefix(p, ":") || strings.ContainsAny(filepath.ToSlash(p), `*?[\`) +} + // emptySnapshotError explains why no file was selected for the snapshot. An empty // tarball is accepted by the upload but fails on the node, after compute was // provisioned, so it is rejected here with the most likely cause. diff --git a/cmd/air/snapshot_package_test.go b/cmd/air/snapshot_package_test.go index cad2a6a5bba..1e4cc13476b 100644 --- a/cmd/air/snapshot_package_test.go +++ b/cmd/air/snapshot_package_test.go @@ -363,3 +363,72 @@ func TestSnapshotFiles_AcceptsIncludePathForms(t *testing.T) { } assert.ElementsMatch(t, []string{"src/model.py", "README.md", "notes.txt"}, rels) } + +func TestSnapshotFiles_IncludePathMatching(t *testing.T) { + tests := []struct { + name string + includePaths []string + want []string // selected files; nil when an error is expected + wantErr string + }{ + {name: "root", includePaths: []string{"."}, want: []string{"README.md", "b.txt", "data_v2/a.csv", "notes.txt", "src/model.py", "src/tests/test_model.py"}}, + {name: "root with slash", includePaths: []string{"./"}, want: []string{"README.md", "b.txt", "data_v2/a.csv", "notes.txt", "src/model.py", "src/tests/test_model.py"}}, + {name: "root and a file", includePaths: []string{".", "README.md"}, want: []string{"README.md", "b.txt", "data_v2/a.csv", "notes.txt", "src/model.py", "src/tests/test_model.py"}}, + {name: "question mark glob", includePaths: []string{"note?.txt"}, want: []string{"notes.txt"}}, + {name: "bracket glob", includePaths: []string{"[ab].txt"}, want: []string{"b.txt"}}, + {name: "exclude magic", includePaths: []string{"src", ":!src/tests"}, want: []string{"src/model.py"}}, + {name: "long exclude magic", includePaths: []string{"src", ":(exclude)src/tests"}, want: []string{"src/model.py"}}, + {name: "prefix of another directory", includePaths: []string{"data_v2", "data"}, wantErr: ": data."}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := t.TempDir() + for _, f := range []string{"README.md", "b.txt", "data_v2/a.csv", "notes.txt", "src/model.py", "src/tests/test_model.py"} { + writeRepoFile(t, repo, f, "x") + } + + files, err := snapshotFiles(t.Context(), repo, tt.includePaths, false) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), "include_paths select no files") + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var rels []string + for _, file := range files { + rels = append(rels, filepath.ToSlash(file.rel)) + } + assert.ElementsMatch(t, tt.want, rels) + }) + } +} + +// git lists a nested repository as a single directory entry, which the tarball +// writer skips, so it must not count as a match or as a file. +func TestSnapshotFiles_NestedRepoSelectsNoFiles(t *testing.T) { + repo := t.TempDir() + writeRepoFile(t, repo, "train.py", "print(1)\n") + nested := filepath.Join(repo, "my_lib") + writeRepoFile(t, nested, "lib.py", "x = 1\n") + runGit(t, nested, "init", "-q") + + _, err := snapshotFiles(t.Context(), repo, []string{"train.py", "my_lib"}, false) + require.Error(t, err) + assert.Contains(t, err.Error(), ": my_lib.") + + _, err = snapshotFiles(t.Context(), repo, []string{"my_lib"}, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "contains no files") +} + +func TestSnapshotFiles_RejectsPathspecOutsideRoot(t *testing.T) { + repo := newTestRepo(t) + writeRepoFile(t, repo, "secret/key.txt", "s") + writeRepoFile(t, repo, "proj/main.py", "print(1)\n") + commitAll(t, repo, "init") + + _, err := snapshotFiles(t.Context(), filepath.Join(repo, "proj"), []string{"main.py", ":(top)secret"}, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "outside root_path") +} From ca3efe24e84aa54a6046eefdbe3c88f7366803f8 Mon Sep 17 00:00:00 2001 From: dimk0 <4886215+dimk0@users.noreply.github.com> Date: Sun, 15 Nov 2026 06:22:14 +0000 Subject: [PATCH 4/5] Point the changelog entry at #7643 Co-authored-by: Isaac --- .nextchanges/air/7639.md | 1 - .nextchanges/air/7643.md | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 .nextchanges/air/7639.md create mode 100644 .nextchanges/air/7643.md diff --git a/.nextchanges/air/7639.md b/.nextchanges/air/7639.md deleted file mode 100644 index 6649b534fdd..00000000000 --- a/.nextchanges/air/7639.md +++ /dev/null @@ -1 +0,0 @@ -* `air run` now rejects a `code_source.include_paths` entry that matches no files instead of uploading a snapshot without it. ([#7639](https://github.com/databricks/cli/pull/7639)) diff --git a/.nextchanges/air/7643.md b/.nextchanges/air/7643.md new file mode 100644 index 00000000000..908a8c81994 --- /dev/null +++ b/.nextchanges/air/7643.md @@ -0,0 +1 @@ +* `air run` now rejects a `code_source.include_paths` path that selects no files instead of uploading a snapshot without it; glob patterns and pathspec magic are left to git. ([#7643](https://github.com/databricks/cli/pull/7643)) From c9dec55efe82ac761ddfa587cde0bc6b08a7561d Mon Sep 17 00:00:00 2001 From: dimk0 <4886215+dimk0@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:25:59 +0000 Subject: [PATCH 5/5] air: show the git ignore-rule hint only for git roots, mention nested repositories - Only suggest `git check-ignore -v` when root_path is in a git repository; outside one the command fails. - The empty-snapshot errors now say that nested git repositories and submodules add no files, since such entries no longer count as files. - Test a backslash-escaped entry, which git reads as a pattern, and the hint outside git. Co-authored-by: Isaac --- cmd/air/snapshot_package.go | 15 ++++++++++++--- cmd/air/snapshot_package_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/cmd/air/snapshot_package.go b/cmd/air/snapshot_package.go index a7aebcd91f0..152a7a4e648 100644 --- a/cmd/air/snapshot_package.go +++ b/cmd/air/snapshot_package.go @@ -205,7 +205,7 @@ func snapshotFiles(ctx context.Context, repoPath string, includePaths []string, return nil, emptySnapshotError(ctx, repoPath, includePaths, isGitRepo) } if unmatched := unmatchedIncludePaths(includePaths, files); len(unmatched) > 0 { - return nil, fmt.Errorf("code_source include_paths select no files under %s: %s. Check that these paths exist under root_path and hold files git does not ignore (`git check-ignore -v ` shows the rule for a file); empty directories, nested git repositories and submodules add no files", filepath.ToSlash(repoPath), strings.Join(unmatched, ", ")) + return nil, fmt.Errorf("code_source include_paths select no files under %s: %s. Check that these paths exist under root_path and hold files git does not ignore%s; empty directories, nested git repositories and submodules add no files", filepath.ToSlash(repoPath), strings.Join(unmatched, ", "), ignoreRuleHint(isGitRepo)) } return files, nil } @@ -258,7 +258,16 @@ func emptySnapshotError(ctx context.Context, repoPath string, includePaths []str } } if len(includePaths) > 0 { - return fmt.Errorf("%s: no file under include_paths (%s) is selected. Check that the paths exist under root_path and are not ignored by git (`git check-ignore -v `)", prefix, strings.Join(includePaths, ", ")) + return fmt.Errorf("%s: no file under include_paths (%s) is selected. Check that the paths exist under root_path and hold files git does not ignore%s; empty directories, nested git repositories and submodules add no files", prefix, strings.Join(includePaths, ", "), ignoreRuleHint(isGitRepo)) } - return fmt.Errorf("%s: the directory is empty or all of its files are excluded by .gitignore rules (`git check-ignore -v ` shows the rule for a file). Check code_source.root_path", prefix) + return fmt.Errorf("%s: the directory is empty, holds only nested git repositories or submodules, or all of its files are excluded by .gitignore rules%s. Check code_source.root_path", prefix, ignoreRuleHint(isGitRepo)) +} + +// ignoreRuleHint points at git's ignore-rule lookup, which only works inside a git +// repository. +func ignoreRuleHint(isGitRepo bool) string { + if !isGitRepo { + return "" + } + return " (`git check-ignore -v ` shows the rule for a file)" } diff --git a/cmd/air/snapshot_package_test.go b/cmd/air/snapshot_package_test.go index 1e4cc13476b..f8b6d775ac2 100644 --- a/cmd/air/snapshot_package_test.go +++ b/cmd/air/snapshot_package_test.go @@ -6,6 +6,7 @@ import ( "context" "os" "path/filepath" + "runtime" "slices" "strings" "testing" @@ -432,3 +433,28 @@ func TestSnapshotFiles_RejectsPathspecOutsideRoot(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "outside root_path") } + +// git reads a backslash in a pathspec as an escape, so the entry is a pattern and is +// left to git. On Windows a backslash is a path separator instead. +func TestSnapshotFiles_AcceptsBackslashEscapedIncludePath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("a backslash is a path separator on Windows") + } + repo := t.TempDir() + writeRepoFile(t, repo, "my file.txt", "x") + writeRepoFile(t, repo, "other.txt", "x") + + files, err := snapshotFiles(t.Context(), repo, []string{`my\ file.txt`}, false) + require.NoError(t, err) + require.Len(t, files, 1) + assert.Equal(t, "my file.txt", filepath.ToSlash(files[0].rel)) +} + +func TestSnapshotFiles_OmitsIgnoreRuleHintOutsideGit(t *testing.T) { + repo := t.TempDir() + writeRepoFile(t, repo, "main.py", "print(1)\n") + + _, err := snapshotFiles(t.Context(), repo, []string{"main.py", "missing"}, false) + require.Error(t, err) + assert.NotContains(t, err.Error(), "check-ignore") +}