Skip to content
Open
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
38 changes: 13 additions & 25 deletions internal/execute/build/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ func (o *Orchestrator) packageJsonLookupChanged(packageJson string, changedPaths
}

func (o *Orchestrator) computeDesiredWatches() map[string]bool {
desiredDirs := make(map[string]bool)
desiredDirs := watchmanager.NewDirWatchSet(o.comparePathsOptions)

for i := range o.order {
config := o.order[i]
Expand All @@ -427,9 +427,7 @@ func (o *Orchestrator) computeDesiredWatches() map[string]bool {
// Watch config file directory
configDir := tspath.GetDirectoryPath(task.config)
realConfigDir := o.host.FS().Realpath(configDir)
if _, has := desiredDirs[realConfigDir]; !has {
desiredDirs[realConfigDir] = false
}
desiredDirs.Set(realConfigDir, false)

if task.resolved == nil {
continue
Expand All @@ -439,29 +437,21 @@ func (o *Orchestrator) computeDesiredWatches() map[string]bool {
for _, cfgPath := range task.resolved.ExtendedSourceFiles() {
realPath := o.host.FS().Realpath(cfgPath)
dir := tspath.GetDirectoryPath(realPath)
if _, has := desiredDirs[dir]; !has {
desiredDirs[dir] = false
}
desiredDirs.Set(dir, false)
}

// Wildcard directories from tsconfig
for dir, recursive := range task.resolved.WildcardDirectories() {
realDir := o.host.FS().Realpath(dir)
if existing, has := desiredDirs[realDir]; has {
desiredDirs[realDir] = existing || recursive
} else {
desiredDirs[realDir] = recursive
}
desiredDirs.Set(realDir, recursive)
}

// Input file directories not already covered
for _, fileName := range task.resolved.FileNames() {
absPath := tspath.GetNormalizedAbsolutePath(fileName, o.opts.Sys.GetCurrentDirectory())
dir := tspath.GetDirectoryPath(absPath)
if !watchmanager.IsDirCoveredByWatch(desiredDirs, dir, o.comparePathsOptions) {
if watchmanager.CanWatchDirectory(dir) {
desiredDirs[dir] = false
}
if !desiredDirs.Covered(dir) && watchmanager.CanWatchDirectory(dir) {
desiredDirs.Set(dir, false)
}
}

Expand All @@ -479,10 +469,8 @@ func (o *Orchestrator) computeDesiredWatches() map[string]bool {
continue
}
dir := tspath.GetDirectoryPath(absPath)
if !watchmanager.IsDirCoveredByWatch(desiredDirs, dir, o.comparePathsOptions) {
if watchmanager.CanWatchDirectory(dir) {
desiredDirs[dir] = false
}
if !desiredDirs.Covered(dir) && watchmanager.CanWatchDirectory(dir) {
desiredDirs.Set(dir, false)
}
}
for packageJson := range bi.buildInfo.GetPackageJsons(buildInfoDir) {
Expand All @@ -497,16 +485,16 @@ func (o *Orchestrator) computeDesiredWatches() map[string]bool {
}
}

return o.wm.ResolveDesiredDirs(desiredDirs)
return o.wm.ResolveDesiredDirs(desiredDirs.Dirs())
}

func (o *Orchestrator) addWatchDir(desiredDirs map[string]bool, dir string) {
if !watchmanager.IsDirCoveredByWatch(desiredDirs, dir, o.comparePathsOptions) && watchmanager.CanWatchDirectory(dir) {
desiredDirs[dir] = false
func (o *Orchestrator) addWatchDir(desiredDirs *watchmanager.DirWatchSet, dir string) {
if !desiredDirs.Covered(dir) && watchmanager.CanWatchDirectory(dir) {
desiredDirs.Set(dir, false)
}
}

func (o *Orchestrator) addPackageJsonWatchDirs(desiredDirs map[string]bool, packageJson string) {
func (o *Orchestrator) addPackageJsonWatchDirs(desiredDirs *watchmanager.DirWatchSet, packageJson string) {
dir := tspath.GetDirectoryPath(packageJson)
dirs := []string{dir}
foundNodeModules := false
Expand Down
13 changes: 7 additions & 6 deletions internal/execute/watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,18 +184,19 @@ func (w *Watcher) computeDesiredWatches(seenFilePaths []string) map[string]bool
// Resolve ancestor fallbacks first so coverage checks use final dirs.
resolvedDirs := w.wm.ResolveDesiredDirs(desiredDirs)

opts := w.comparePathsOptions()
coverage := watchmanager.NewDirWatchSet(w.comparePathsOptions())
for dir, recursive := range resolvedDirs {
coverage.Set(dir, recursive)
}
for _, filePath := range seenFilePaths {
dir := tspath.GetDirectoryPath(filePath)
if !watchmanager.IsDirCoveredByWatch(resolvedDirs, dir, opts) {
if watchmanager.CanWatchDirectory(dir) {
resolvedDirs[dir] = false
}
if !coverage.Covered(dir) && watchmanager.CanWatchDirectory(dir) {
coverage.Set(dir, false)
}
}

// Re-resolve in case newly added dirs don't exist
return w.wm.ResolveDesiredDirs(resolvedDirs)
return w.wm.ResolveDesiredDirs(coverage.Dirs())
}

func (w *Watcher) reconcileWatches(seenFilePaths []string) error {
Expand Down
50 changes: 43 additions & 7 deletions internal/execute/watchmanager/watchmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,19 +306,55 @@ func (wm *WatchManager) createDirWatches(updates []dirWatchUpdate) error {
return nil
}

func IsDirCoveredByWatch(dirs map[string]bool, dir string, opts tspath.ComparePathsOptions) bool {
for wdir, recursive := range dirs {
if recursive {
if tspath.ContainsPath(wdir, dir, opts) {
return true
}
} else if tspath.ComparePaths(dir, wdir, opts) == 0 {
// DirWatchSet accumulates the set of directories that should be watched while
// answering coverage queries efficiently. A directory is "covered" when it is
// already present in the set, or when it is contained within a recursive watch
// directory already in the set.
type DirWatchSet struct {
opts tspath.ComparePathsOptions
dirs map[string]bool
present map[string]struct{}
recursive []string
}

func NewDirWatchSet(opts tspath.ComparePathsOptions) *DirWatchSet {
return &DirWatchSet{
opts: opts,
dirs: make(map[string]bool),
present: make(map[string]struct{}),
}
}

func (s *DirWatchSet) canonical(dir string) string {
return tspath.GetCanonicalFileName(dir, s.opts.UseCaseSensitiveFileNames)
}

func (s *DirWatchSet) Set(dir string, recursive bool) {
existing, has := s.dirs[dir]
newRecursive := existing || recursive
s.dirs[dir] = newRecursive
s.present[s.canonical(dir)] = struct{}{}
if newRecursive && (!has || !existing) {
s.recursive = append(s.recursive, dir)
}
}

func (s *DirWatchSet) Covered(dir string) bool {
if _, has := s.present[s.canonical(dir)]; has {
return true
}
for _, wdir := range s.recursive {
if tspath.ContainsPath(wdir, dir, s.opts) {
return true
}
}
return false
}

func (s *DirWatchSet) Dirs() map[string]bool {
return s.dirs
}

func (wm *WatchManager) IsPathUnderWatch(path string, opts tspath.ComparePathsOptions) bool {
for dir := range wm.watchedDirs {
if tspath.ContainsPath(dir, path, opts) {
Expand Down
130 changes: 130 additions & 0 deletions internal/execute/watchmanager/watchmanager_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package watchmanager

import (
"testing"

"github.com/microsoft/typescript-go/internal/tspath"
"gotest.tools/v3/assert"
)

var (
caseSensitiveOpts = tspath.ComparePathsOptions{UseCaseSensitiveFileNames: true, CurrentDirectory: "/repo"}
caseInsensitiveOpts = tspath.ComparePathsOptions{UseCaseSensitiveFileNames: false, CurrentDirectory: "/repo"}
)

// TestDirWatchSetCoverage checks the core coverage rules: a recursive watch
// covers itself and all descendants, while a non-recursive watch covers only
// itself. Ancestors and unrelated paths are never covered.
func TestDirWatchSetCoverage(t *testing.T) {
t.Parallel()

set := NewDirWatchSet(caseSensitiveOpts)
set.Set("/repo/src", true) // recursive
set.Set("/repo/config", false) // non-recursive
set.Set("/repo/node_modules/a", false) // non-recursive

tests := []struct {
dir string
want bool
}{
{"/repo/src", true}, // exact recursive
{"/repo/src/nested", true}, // descendant of recursive
{"/repo/src/nested/deep", true}, // deep descendant of recursive
{"/repo/config", true}, // exact non-recursive
{"/repo/config/nested", false}, // descendant of non-recursive: NOT covered
{"/repo/node_modules/a", true}, // exact non-recursive
{"/repo/node_modules/b", false}, // sibling, absent
{"/repo", false}, // ancestor of watched dirs: NOT covered
{"/other", false}, // unrelated
}
for _, tt := range tests {
assert.Equal(t, set.Covered(tt.dir), tt.want, "Covered(%q)", tt.dir)
}
}

// TestDirWatchSetCaseSensitive verifies that on a case-sensitive filesystem a
// differently-cased directory is a distinct, uncovered directory.
func TestDirWatchSetCaseSensitive(t *testing.T) {
t.Parallel()

set := NewDirWatchSet(caseSensitiveOpts)
set.Set("/repo/node_modules/a", false)
set.Set("/repo/Src", true)

assert.Assert(t, set.Covered("/repo/node_modules/a"))
assert.Assert(t, !set.Covered("/repo/node_modules/A"), "case-sensitive FS must not cover differently-cased dir")
assert.Assert(t, set.Covered("/repo/Src/nested"), "recursive descendant with matching case is covered")
assert.Assert(t, !set.Covered("/repo/src/nested"), "case-sensitive FS must not cover differently-cased descendant")
}

// TestDirWatchSetCaseInsensitive verifies that on a case-insensitive filesystem
// coverage ignores casing for both exact matches and recursive containment.
func TestDirWatchSetCaseInsensitive(t *testing.T) {
t.Parallel()

set := NewDirWatchSet(caseInsensitiveOpts)
set.Set("/repo/node_modules/a", false)
set.Set("/repo/Src", true)

assert.Assert(t, set.Covered("/repo/node_modules/A"), "exact match should be case-insensitive")
assert.Assert(t, set.Covered("/REPO/NODE_MODULES/a"), "exact match should be case-insensitive across components")
assert.Assert(t, set.Covered("/repo/src/nested/deep"), "recursive containment should be case-insensitive")
}

// TestDirWatchSetPreservesCasing verifies Dirs returns original-cased keys so
// watch registration uses the real path even on case-insensitive filesystems.
func TestDirWatchSetPreservesCasing(t *testing.T) {
t.Parallel()

set := NewDirWatchSet(caseInsensitiveOpts)
set.Set("/repo/Node_Modules/PkgName", false)

_, ok := set.Dirs()["/repo/Node_Modules/PkgName"]
assert.Assert(t, ok, "Dirs must preserve original casing for watch registration")
_, lowered := set.Dirs()["/repo/node_modules/pkgname"]
assert.Assert(t, !lowered, "Dirs must not contain a canonicalized (lowercased) key")
}

// TestDirWatchSetUpgradeToRecursive verifies that upgrading a directory from
// non-recursive to recursive begins covering its descendants.
func TestDirWatchSetUpgradeToRecursive(t *testing.T) {
t.Parallel()

set := NewDirWatchSet(caseSensitiveOpts)
set.Set("/repo/src", false)
assert.Assert(t, set.Covered("/repo/src"))
assert.Assert(t, !set.Covered("/repo/src/nested"), "descendant not covered while non-recursive")

set.Set("/repo/src", true)
assert.Assert(t, set.Covered("/repo/src/nested"), "descendant covered after upgrade to recursive")
assert.Equal(t, set.Dirs()["/repo/src"], true)
}

// TestDirWatchSetNeverDowngrades verifies a recursive watch is not downgraded by
// a subsequent non-recursive Set of the same directory.
func TestDirWatchSetNeverDowngrades(t *testing.T) {
t.Parallel()

set := NewDirWatchSet(caseSensitiveOpts)
set.Set("/repo/src", true)
set.Set("/repo/src", false)

assert.Equal(t, set.Dirs()["/repo/src"], true)
assert.Assert(t, set.Covered("/repo/src/nested"), "recursive coverage retained after non-recursive Set")
}

// TestDirWatchSetDirs verifies the emitted map reflects every added directory
// with the expected recursive flags.
func TestDirWatchSetDirs(t *testing.T) {
t.Parallel()

set := NewDirWatchSet(caseSensitiveOpts)
set.Set("/repo/a", false)
set.Set("/repo/b", true)
set.Set("/repo/a", false) // duplicate non-recursive add is idempotent

dirs := set.Dirs()
assert.Equal(t, len(dirs), 2)
assert.Equal(t, dirs["/repo/a"], false)
assert.Equal(t, dirs["/repo/b"], true)
}