Skip to content

[pull] main from coder:main #111

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions agent/agentcontainers/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}

Expand All @@ -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(),
Expand Down
113 changes: 112 additions & 1 deletion agent/agentcontainers/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
124 changes: 124 additions & 0 deletions agent/agentcontainers/ignore/dir.go
Original file line number Diff line number Diff line change
@@ -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))
}
38 changes: 38 additions & 0 deletions agent/agentcontainers/ignore/dir_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
Loading
Loading