Skip to content
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
4 changes: 3 additions & 1 deletion cmd/devbox/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ func syncSources(ctx context.Context, p *project.Project, onSyncing, onSynced, o
ctx, cancelSync := context.WithCancel(ctx)
defer cancelSync()

excludes := p.SourceCleanExcludes()

const maxConcurrency = 4
sem := make(chan struct{}, maxConcurrency)

Expand All @@ -149,7 +151,7 @@ func syncSources(ctx context.Context, p *project.Project, onSyncing, onSynced, o

repoDir := filepath.Join(p.WorkingDir, app.SourcesDir, name)

g := git.New(repoDir)
g := git.New(repoDir, excludes[name]...)
err := g.Sync(ctx, src.URL, src.Branch, src.SparseCheckout)
if err != nil {
onFailed(name)
Expand Down
13 changes: 11 additions & 2 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,16 @@ var _ Service = (*svc)(nil)
type svc struct {
targetPath string
runner CommandRunner
excludes []string
}

func New(targetFolder string) Service {
// New returns a git Service for targetFolder. Any excludes are passed to `git clean` as
// `-e <pattern>` so live nested bind-mount points are preserved during source sync.
func New(targetFolder string, excludes ...string) Service {
return &svc{
targetPath: targetFolder,
runner: &defaultRunner{},
excludes: excludes,
}
}

Expand Down Expand Up @@ -178,7 +182,12 @@ func (s *svc) reset(ctx context.Context, removeIgnored bool) error {
cleanFlags = "-fdx"
}

out, err = s.runner.Run(ctx, "git", "-C", s.targetPath, "clean", cleanFlags)
cleanArgs := []string{"-C", s.targetPath, "clean", cleanFlags}
for _, e := range s.excludes {
cleanArgs = append(cleanArgs, "-e", e)
}

out, err = s.runner.Run(ctx, "git", cleanArgs...)
if err != nil {
return fmt.Errorf("failed to clean: %s %w", out, err)
}
Expand Down
130 changes: 130 additions & 0 deletions internal/git/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
Expand Down Expand Up @@ -175,6 +176,135 @@ func TestPull(t *testing.T) {
}
}

// ============================================================================
// Excludes (git clean -e) tests
// ============================================================================

func TestResetWithExcludes(t *testing.T) {
tests := []struct {
name string
excludes []string
removeIgnored bool
wantCleanArgs []any
}{
{
name: "single exclude with -fdx",
excludes: []string{"cmd/service-a/shared"},
removeIgnored: true,
wantCleanArgs: []any{"-C", "/tmp/test", "clean", "-fdx", "-e", "cmd/service-a/shared"},
},
{
name: "single exclude with -fd",
excludes: []string{"cmd/service-a/shared"},
removeIgnored: false,
wantCleanArgs: []any{"-C", "/tmp/test", "clean", "-fd", "-e", "cmd/service-a/shared"},
},
{
name: "two excludes keep separate -e in slice order",
excludes: []string{"a", "b"},
removeIgnored: true,
wantCleanArgs: []any{"-C", "/tmp/test", "clean", "-fdx", "-e", "a", "-e", "b"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
runner := NewMockCommandRunner(t)
runner.EXPECT().Run(mock.Anything, "git", "-C", "/tmp/test", "reset", "--hard").Return("", nil)
runner.EXPECT().Run(mock.Anything, "git", tt.wantCleanArgs...).Return("", nil)

s := &svc{targetPath: "/tmp/test", runner: runner, excludes: tt.excludes}
if err := s.reset(context.Background(), tt.removeIgnored); err != nil {
t.Errorf("reset() error = %v", err)
}
})
}
}

// TestSyncWithExcludes proves both git clean invocations Sync makes on an existing repo
// (reset's -fdx and the trailing Pull's -fd) receive the excludes.
func TestSyncWithExcludes(t *testing.T) {
dir := t.TempDir()
targetPath := filepath.Join(dir, "repo")
_ = os.MkdirAll(filepath.Join(targetPath, ".git"), 0o755)

exclude := "cmd/service-a/shared"

runner := NewMockCommandRunner(t)
// Sync's reset (removeIgnored=true)
runner.EXPECT().Run(mock.Anything, "git", "-C", targetPath, "reset", "--hard").Return("", nil).Once()
runner.EXPECT().Run(mock.Anything, "git", "-C", targetPath, "clean", "-fdx", "-e", exclude).Return("", nil).Once()
runner.EXPECT().Run(mock.Anything, "git", "-C", targetPath, "sparse-checkout", "disable").Return("", nil)
runner.EXPECT().Run(mock.Anything, "git", "-C", targetPath, "checkout", "main").Return("", nil)
// Trailing Pull's reset (removeIgnored=false)
runner.EXPECT().Run(mock.Anything, "git", "-C", targetPath, "reset", "--hard").Return("", nil).Once()
runner.EXPECT().Run(mock.Anything, "git", "-C", targetPath, "clean", "-fd", "-e", exclude).Return("", nil).Once()
runner.EXPECT().RunWithTTY(mock.Anything, "git", "-C", targetPath, "pull", "--rebase").Return("", nil)

s := &svc{targetPath: targetPath, runner: runner, excludes: []string{exclude}}
if err := s.Sync(context.Background(), "https://github.com/org/repo.git", "main", nil); err != nil {
t.Errorf("Sync() error = %v", err)
}
}

// TestGitCleanExcludePreservesMountpoint runs real `git clean -fdx -e <path>` against a temp
// repo to prove the computed exclude string actually anchors: the excluded dir survives while
// sibling untracked cruft is removed. A wrong string format would slip past the mock tests but
// fail here.
func TestGitCleanExcludePreservesMountpoint(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}

dir := t.TempDir()
runGit := func(args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
cmd.Env = append(os.Environ(),
"GIT_CONFIG_GLOBAL=/dev/null",
"GIT_CONFIG_SYSTEM=/dev/null",
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t",
)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}

trackedDir := filepath.Join(dir, "cmd", "service-a")
if err := os.MkdirAll(trackedDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(trackedDir, "main.go"), []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}

runGit("init")
runGit("add", ".")
runGit("commit", "-m", "init")

mountpoint := filepath.Join(trackedDir, "shared")
if err := os.MkdirAll(mountpoint, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mountpoint, "keep.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
stale := filepath.Join(trackedDir, "stale.tmp")
if err := os.WriteFile(stale, []byte("y"), 0o644); err != nil {
t.Fatal(err)
}

runGit("clean", "-fdx", "-e", "cmd/service-a/shared")

if _, err := os.Stat(mountpoint); err != nil {
t.Errorf("excluded mountpoint should survive clean: %v", err)
}
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Errorf("stale.tmp should have been removed by clean, stat err = %v", err)
}
}

// ============================================================================
// GetInfo tests
// ============================================================================
Expand Down
2 changes: 1 addition & 1 deletion internal/manager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ func (m *Manager) matchSourcePath(

// Check if cwd matches the service's source path:
// - If service uses root (serviceSubpath=""), cwd must also be at root
// - If service uses subpath (e.g., "cmd/risk-engine"), cwd must be inside it
// - If service uses subpath (e.g., "cmd/app"), cwd must be inside it
if !cwdMatchesServiceSubpath(cwdRelPath, serviceSubpath) {
return "", "", false
}
Expand Down
107 changes: 107 additions & 0 deletions internal/project/mounts_exclude.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package project

import (
"path"
"path/filepath"
"slices"
"strings"

"github.com/compose-spec/compose-go/v2/types"

"github.com/pilat/devbox/internal/app"
)

// SourceCleanExcludes returns, per source name, the repo-relative paths `git clean` must skip
// because Docker mounts a different source into them as a live nested bind mount. Keys match the
// on-disk sources/<name> segment; values carry no leading slash.
func (p *Project) SourceCleanExcludes() map[string][]string {
sourcesRoot := filepath.Join(p.WorkingDir, app.SourcesDir)

return nestedForeignMountExcludes(p.Services, sourcesRoot)
}

// nestedForeignMountExcludes derives cross-source nested-mount excludes from the compose volume
// topology: a bind volume (child) of a source is mounted nested inside a same-service parent bind
// whose host source is a *different* source. The different-source discriminator keeps self-nested
// and file/config mounts out, so clean is never weakened where it should still run.
func nestedForeignMountExcludes(services types.Services, sourcesRoot string) map[string][]string {
type bindVol struct {
source string
target string
}

result := make(map[string][]string)

for _, service := range services {
binds := make([]bindVol, 0, len(service.Volumes))
for _, v := range service.Volumes {
if v.Type != "bind" {
continue
}
binds = append(binds, bindVol{source: v.Source, target: path.Clean(v.Target)})
}

for _, child := range binds {
childSrc, ok := sourceSegment(child.source, sourcesRoot)
if !ok {
continue
}

var parent *bindVol
for i := range binds {
cand := &binds[i]
if !strings.HasPrefix(child.target, cand.target+"/") {
continue
}
if parent == nil || len(cand.target) > len(parent.target) {
parent = cand
}
}
if parent == nil {
continue
}

parentSrc, ok := sourceSegment(parent.source, sourcesRoot)
if !ok {
continue
}

if childSrc == parentSrc {
continue
}

childRel := strings.TrimPrefix(strings.TrimPrefix(child.target, parent.target), "/")
hostMountpoint := filepath.Join(parent.source, childRel)
repoRel, err := filepath.Rel(filepath.Join(sourcesRoot, parentSrc), hostMountpoint)
if err != nil {
continue
}

result[parentSrc] = append(result[parentSrc], repoRel)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

for src, excludes := range result {
slices.Sort(excludes)
result[src] = slices.Compact(excludes)
}

return result
}

// sourceSegment reports the sources/<name> segment of an absolute host path, if it lives under
// sourcesRoot. It returns false for anything outside sourcesRoot (envs/, named volumes, or a
// LocalMounts-rewritten local path).
func sourceSegment(hostPath, sourcesRoot string) (string, bool) {
prefix := sourcesRoot + string(filepath.Separator)
if !strings.HasPrefix(hostPath, prefix) {
return "", false
}

seg, _, _ := strings.Cut(hostPath[len(prefix):], string(filepath.Separator))
if seg == "" {
return "", false
}

return seg, true
}
Loading