mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(agent/agentcontainers): respect ignore files (#19016)
Closes https://github.com/coder/coder/issues/19011 We now use [go-git](https://pkg.go.dev/github.com/go-git/go-git/v5@v5.16.2/plumbing/format/gitignore)'s `gitignore` plumbing implementation to parse the `.gitignore` files and match against the patterns generated. We use this to ignore any ignored files in the git repository. Unfortunately I've had to slightly re-implement some of the interface exposed by `go-git` because they use `billy.Filesystem` instead of `afero.Fs`.
This commit is contained in:
@@ -21,11 +21,13 @@ import (
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/afero"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers/ignore"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers/watcher"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/agent/usershell"
|
||||
@@ -469,13 +471,49 @@ func (api *API) discoverDevcontainerProjects() error {
|
||||
}
|
||||
|
||||
func (api *API) discoverDevcontainersInProject(projectPath string) error {
|
||||
logger := api.logger.
|
||||
Named("project-discovery").
|
||||
With(slog.F("project_path", projectPath))
|
||||
|
||||
globalPatterns, err := ignore.LoadGlobalPatterns(api.fs)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("read global git ignore patterns: %w", err)
|
||||
}
|
||||
|
||||
patterns, err := ignore.ReadPatterns(api.ctx, logger, api.fs, projectPath)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("read git ignore patterns: %w", err)
|
||||
}
|
||||
|
||||
matcher := gitignore.NewMatcher(append(globalPatterns, patterns...))
|
||||
|
||||
devcontainerConfigPaths := []string{
|
||||
"/.devcontainer/devcontainer.json",
|
||||
"/.devcontainer.json",
|
||||
}
|
||||
|
||||
return afero.Walk(api.fs, projectPath, func(path string, info fs.FileInfo, _ error) error {
|
||||
return afero.Walk(api.fs, projectPath, func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
logger.Error(api.ctx, "encountered error while walking for dev container projects",
|
||||
slog.F("path", path),
|
||||
slog.Error(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
pathParts := ignore.FilePathToParts(path)
|
||||
|
||||
// We know that a directory entry cannot be a `devcontainer.json` file, so we
|
||||
// always skip processing directories. If the directory happens to be ignored
|
||||
// by git then we'll make sure to ignore all of the children of that directory.
|
||||
if info.IsDir() {
|
||||
if matcher.Match(pathParts, true) {
|
||||
return fs.SkipDir
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if matcher.Match(pathParts, false) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -486,11 +524,11 @@ func (api *API) discoverDevcontainersInProject(projectPath string) error {
|
||||
|
||||
workspaceFolder := strings.TrimSuffix(path, relativeConfigPath)
|
||||
|
||||
api.logger.Debug(api.ctx, "discovered dev container project", slog.F("workspace_folder", workspaceFolder))
|
||||
logger.Debug(api.ctx, "discovered dev container project", slog.F("workspace_folder", workspaceFolder))
|
||||
|
||||
api.mu.Lock()
|
||||
if _, found := api.knownDevcontainers[workspaceFolder]; !found {
|
||||
api.logger.Debug(api.ctx, "adding dev container project", slog.F("workspace_folder", workspaceFolder))
|
||||
logger.Debug(api.ctx, "adding dev container project", slog.F("workspace_folder", workspaceFolder))
|
||||
|
||||
dc := codersdk.WorkspaceAgentDevcontainer{
|
||||
ID: uuid.New(),
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -3211,6 +3212,9 @@ func TestDevcontainerDiscovery(t *testing.T) {
|
||||
// repositories to find any `.devcontainer/devcontainer.json`
|
||||
// files. These tests are to validate that behavior.
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
agentDir string
|
||||
@@ -3345,6 +3349,113 @@ func TestDevcontainerDiscovery(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "RespectGitIgnore",
|
||||
agentDir: "/home/coder",
|
||||
fs: map[string]string{
|
||||
"/home/coder/coder/.git/HEAD": "",
|
||||
"/home/coder/coder/.gitignore": "y/",
|
||||
"/home/coder/coder/.devcontainer.json": "",
|
||||
"/home/coder/coder/x/y/.devcontainer.json": "",
|
||||
},
|
||||
expected: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{
|
||||
WorkspaceFolder: "/home/coder/coder",
|
||||
ConfigPath: "/home/coder/coder/.devcontainer.json",
|
||||
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "RespectNestedGitIgnore",
|
||||
agentDir: "/home/coder",
|
||||
fs: map[string]string{
|
||||
"/home/coder/coder/.git/HEAD": "",
|
||||
"/home/coder/coder/.devcontainer.json": "",
|
||||
"/home/coder/coder/y/.devcontainer.json": "",
|
||||
"/home/coder/coder/x/.gitignore": "y/",
|
||||
"/home/coder/coder/x/y/.devcontainer.json": "",
|
||||
},
|
||||
expected: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{
|
||||
WorkspaceFolder: "/home/coder/coder",
|
||||
ConfigPath: "/home/coder/coder/.devcontainer.json",
|
||||
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
|
||||
},
|
||||
{
|
||||
WorkspaceFolder: "/home/coder/coder/y",
|
||||
ConfigPath: "/home/coder/coder/y/.devcontainer.json",
|
||||
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "RespectGitInfoExclude",
|
||||
agentDir: "/home/coder",
|
||||
fs: map[string]string{
|
||||
"/home/coder/coder/.git/HEAD": "",
|
||||
"/home/coder/coder/.git/info/exclude": "y/",
|
||||
"/home/coder/coder/.devcontainer.json": "",
|
||||
"/home/coder/coder/x/y/.devcontainer.json": "",
|
||||
},
|
||||
expected: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{
|
||||
WorkspaceFolder: "/home/coder/coder",
|
||||
ConfigPath: "/home/coder/coder/.devcontainer.json",
|
||||
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "RespectHomeGitConfig",
|
||||
agentDir: homeDir,
|
||||
fs: map[string]string{
|
||||
"/tmp/.gitignore": "node_modules/",
|
||||
filepath.Join(homeDir, ".gitconfig"): `
|
||||
[core]
|
||||
excludesFile = /tmp/.gitignore
|
||||
`,
|
||||
|
||||
filepath.Join(homeDir, ".git/HEAD"): "",
|
||||
filepath.Join(homeDir, ".devcontainer.json"): "",
|
||||
filepath.Join(homeDir, "node_modules/y/.devcontainer.json"): "",
|
||||
},
|
||||
expected: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{
|
||||
WorkspaceFolder: homeDir,
|
||||
ConfigPath: filepath.Join(homeDir, ".devcontainer.json"),
|
||||
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IgnoreNonsenseDevcontainerNames",
|
||||
agentDir: "/home/coder",
|
||||
fs: map[string]string{
|
||||
"/home/coder/.git/HEAD": "",
|
||||
|
||||
"/home/coder/.devcontainer/devcontainer.json.bak": "",
|
||||
"/home/coder/.devcontainer/devcontainer.json.old": "",
|
||||
"/home/coder/.devcontainer/devcontainer.json~": "",
|
||||
"/home/coder/.devcontainer/notdevcontainer.json": "",
|
||||
"/home/coder/.devcontainer/devcontainer.json.swp": "",
|
||||
|
||||
"/home/coder/foo/.devcontainer.json.bak": "",
|
||||
"/home/coder/foo/.devcontainer.json.old": "",
|
||||
"/home/coder/foo/.devcontainer.json~": "",
|
||||
"/home/coder/foo/.notdevcontainer.json": "",
|
||||
"/home/coder/foo/.devcontainer.json.swp": "",
|
||||
|
||||
"/home/coder/bar/.devcontainer.json": "",
|
||||
},
|
||||
expected: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{
|
||||
WorkspaceFolder: "/home/coder/bar",
|
||||
ConfigPath: "/home/coder/bar/.devcontainer.json",
|
||||
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
initFS := func(t *testing.T, files map[string]string) afero.Fs {
|
||||
@@ -3397,7 +3508,7 @@ func TestDevcontainerDiscovery(t *testing.T) {
|
||||
err := json.NewDecoder(rec.Body).Decode(&got)
|
||||
require.NoError(t, err)
|
||||
|
||||
return len(got.Devcontainers) == len(tt.expected)
|
||||
return len(got.Devcontainers) >= len(tt.expected)
|
||||
}, testutil.WaitShort, testutil.IntervalFast, "dev containers never found")
|
||||
|
||||
// Now projects have been discovered, we'll allow the updater loop
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package ignore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing/format/config"
|
||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||
"github.com/spf13/afero"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
)
|
||||
|
||||
const (
|
||||
gitconfigFile = ".gitconfig"
|
||||
gitignoreFile = ".gitignore"
|
||||
gitInfoExcludeFile = ".git/info/exclude"
|
||||
)
|
||||
|
||||
func FilePathToParts(path string) []string {
|
||||
components := []string{}
|
||||
|
||||
if path == "" {
|
||||
return components
|
||||
}
|
||||
|
||||
for segment := range strings.SplitSeq(filepath.Clean(path), string(filepath.Separator)) {
|
||||
if segment != "" {
|
||||
components = append(components, segment)
|
||||
}
|
||||
}
|
||||
|
||||
return components
|
||||
}
|
||||
|
||||
func readIgnoreFile(fileSystem afero.Fs, path, ignore string) ([]gitignore.Pattern, error) {
|
||||
var ps []gitignore.Pattern
|
||||
|
||||
data, err := afero.ReadFile(fileSystem, filepath.Join(path, ignore))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for s := range strings.SplitSeq(string(data), "\n") {
|
||||
if !strings.HasPrefix(s, "#") && len(strings.TrimSpace(s)) > 0 {
|
||||
ps = append(ps, gitignore.ParsePattern(s, FilePathToParts(path)))
|
||||
}
|
||||
}
|
||||
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
func ReadPatterns(ctx context.Context, logger slog.Logger, fileSystem afero.Fs, path string) ([]gitignore.Pattern, error) {
|
||||
var ps []gitignore.Pattern
|
||||
|
||||
subPs, err := readIgnoreFile(fileSystem, path, gitInfoExcludeFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ps = append(ps, subPs...)
|
||||
|
||||
if err := afero.Walk(fileSystem, path, func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
logger.Error(ctx, "encountered error while walking for git ignore files",
|
||||
slog.F("path", path),
|
||||
slog.Error(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
subPs, err := readIgnoreFile(fileSystem, path, gitignoreFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ps = append(ps, subPs...)
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
func loadPatterns(fileSystem afero.Fs, path string) ([]gitignore.Pattern, error) {
|
||||
data, err := afero.ReadFile(fileSystem, path)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
decoder := config.NewDecoder(bytes.NewBuffer(data))
|
||||
|
||||
conf := config.New()
|
||||
if err := decoder.Decode(conf); err != nil {
|
||||
return nil, xerrors.Errorf("decode config: %w", err)
|
||||
}
|
||||
|
||||
excludes := conf.Section("core").Options.Get("excludesfile")
|
||||
if excludes == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return readIgnoreFile(fileSystem, "", excludes)
|
||||
}
|
||||
|
||||
func LoadGlobalPatterns(fileSystem afero.Fs) ([]gitignore.Pattern, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return loadPatterns(fileSystem, filepath.Join(home, gitconfigFile))
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package ignore_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentcontainers/ignore"
|
||||
)
|
||||
|
||||
func TestFilePathToParts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
expected []string
|
||||
}{
|
||||
{"", []string{}},
|
||||
{"/", []string{}},
|
||||
{"foo", []string{"foo"}},
|
||||
{"/foo", []string{"foo"}},
|
||||
{"./foo/bar", []string{"foo", "bar"}},
|
||||
{"../foo/bar", []string{"..", "foo", "bar"}},
|
||||
{"foo/bar/baz", []string{"foo", "bar", "baz"}},
|
||||
{"/foo/bar/baz", []string{"foo", "bar", "baz"}},
|
||||
{"foo/../bar", []string{"bar"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("`%s`", tt.path), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parts := ignore.FilePathToParts(tt.path)
|
||||
require.Equal(t, tt.expected, parts)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ require (
|
||||
github.com/fergusstrange/embedded-postgres v1.31.0
|
||||
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa
|
||||
github.com/gen2brain/beeep v0.11.1
|
||||
github.com/gliderlabs/ssh v0.3.4
|
||||
github.com/gliderlabs/ssh v0.3.8
|
||||
github.com/go-chi/chi/v5 v5.2.2
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/go-chi/httprate v0.15.0
|
||||
@@ -484,6 +484,7 @@ require (
|
||||
github.com/coder/aisdk-go v0.0.9
|
||||
github.com/coder/preview v1.0.3-0.20250714153828-a737d4750448
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/go-git/go-git/v5 v5.16.2
|
||||
github.com/mark3labs/mcp-go v0.34.0
|
||||
)
|
||||
|
||||
@@ -512,10 +513,13 @@ require (
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
||||
github.com/esiqveland/notify v0.13.3 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
||||
github.com/hashicorp/go-getter v1.7.8 // indirect
|
||||
github.com/hashicorp/go-safetemp v1.0.0 // indirect
|
||||
github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||
@@ -535,5 +539,6 @@ require (
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect
|
||||
google.golang.org/genai v1.12.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect
|
||||
)
|
||||
|
||||
@@ -1100,8 +1100,8 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66D
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
|
||||
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
|
||||
github.com/go-git/go-git/v5 v5.16.0 h1:k3kuOEpkc0DeY7xlL6NaaNg39xdgQbtH5mwCafHO9AQ=
|
||||
github.com/go-git/go-git/v5 v5.16.0/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||
github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM=
|
||||
github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
|
||||
Reference in New Issue
Block a user