Skip to content

Commit 30d0988

Browse files
committed
test message
1 parent 3e904d0 commit 30d0988

13 files changed

Lines changed: 996 additions & 17 deletions

File tree

cmd/iterate/features_mcp.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/GrayCodeAI/iterate/internal/commands"
10+
)
11+
12+
// mcpServersPath returns the path to the persisted MCP server list.
13+
func mcpServersPath() string {
14+
home, _ := os.UserHomeDir()
15+
return filepath.Join(home, ".iterate", "mcp_servers.json")
16+
}
17+
18+
// loadMCPServers reads the persisted MCP server list from disk.
19+
func loadMCPServers() []commands.MCPServerEntry {
20+
data, err := os.ReadFile(mcpServersPath())
21+
if err != nil {
22+
return nil
23+
}
24+
var servers []commands.MCPServerEntry
25+
if err := json.Unmarshal(data, &servers); err != nil {
26+
return nil
27+
}
28+
return servers
29+
}
30+
31+
// saveMCPServers writes the MCP server list to disk.
32+
func saveMCPServers(servers []commands.MCPServerEntry) {
33+
if err := os.MkdirAll(filepath.Dir(mcpServersPath()), 0o755); err != nil {
34+
return
35+
}
36+
data, err := json.MarshalIndent(servers, "", " ")
37+
if err != nil {
38+
return
39+
}
40+
_ = os.WriteFile(mcpServersPath(), data, 0o644)
41+
}
42+
43+
// mcpJSONEntry is the shape of an entry in .mcp.json.
44+
type mcpJSONEntry struct {
45+
Name string `json:"name"`
46+
URL string `json:"url,omitempty"`
47+
Command string `json:"command,omitempty"`
48+
Args []string `json:"args,omitempty"`
49+
}
50+
51+
// discoverMCPServers reads .mcp.json from the repo root and merges any
52+
// new entries into the persisted list. Entries already present (by name)
53+
// are not overwritten. Returns the number of newly added servers.
54+
func discoverMCPServers(absRepo string) int {
55+
mcpFile := filepath.Join(absRepo, ".mcp.json")
56+
data, err := os.ReadFile(mcpFile)
57+
if err != nil {
58+
return 0 // file absent — that's fine
59+
}
60+
61+
// Support both top-level array and {servers:[...]} object.
62+
var discovered []mcpJSONEntry
63+
if err := json.Unmarshal(data, &discovered); err != nil {
64+
// Try object wrapper: {"servers": [...]}
65+
var wrapper struct {
66+
Servers []mcpJSONEntry `json:"servers"`
67+
}
68+
if err2 := json.Unmarshal(data, &wrapper); err2 != nil {
69+
fmt.Fprintf(os.Stderr, "warn: .mcp.json parse error: %v\n", err)
70+
return 0
71+
}
72+
discovered = wrapper.Servers
73+
}
74+
75+
if len(discovered) == 0 {
76+
return 0
77+
}
78+
79+
existing := loadMCPServers()
80+
existingNames := make(map[string]bool, len(existing))
81+
for _, s := range existing {
82+
existingNames[s.Name] = true
83+
}
84+
85+
added := 0
86+
for _, d := range discovered {
87+
if d.Name == "" || existingNames[d.Name] {
88+
continue
89+
}
90+
existing = append(existing, commands.MCPServerEntry{
91+
Name: d.Name,
92+
URL: d.URL,
93+
Command: d.Command,
94+
Args: d.Args,
95+
})
96+
existingNames[d.Name] = true
97+
added++
98+
}
99+
100+
if added > 0 {
101+
saveMCPServers(existing)
102+
}
103+
return added
104+
}

cmd/iterate/features_watch.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ package main
22

33
import (
44
"context"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"strings"
510
"sync"
11+
"time"
612
)
713

814
// ---------------------------------------------------------------------------
@@ -13,6 +19,19 @@ import (
1319
var watchCancel context.CancelFunc
1420
var watchMu sync.Mutex
1521

22+
// watchConfig holds debounce and filter settings for the watcher.
23+
var watchConfig = struct {
24+
debounce time.Duration
25+
// include is a list of glob suffixes to watch (e.g. ".go", ".ts").
26+
// If empty, all files are watched.
27+
include []string
28+
// exclude is a list of path substrings to ignore.
29+
exclude []string
30+
}{
31+
debounce: 300 * time.Millisecond,
32+
exclude: []string{".git", "node_modules", ".iterate"},
33+
}
34+
1635
func stopWatch() {
1736
watchMu.Lock()
1837
defer watchMu.Unlock()
@@ -21,3 +40,130 @@ func stopWatch() {
2140
watchCancel = nil
2241
}
2342
}
43+
44+
// startWatch starts a polling-based file watcher that runs `go test ./...`
45+
// (or a custom command) whenever a watched file changes.
46+
// It debounces rapid changes and respects include/exclude filter patterns.
47+
func startWatch(repoPath string) {
48+
watchMu.Lock()
49+
if watchCancel != nil {
50+
watchCancel() // stop any previous watcher
51+
}
52+
ctx, cancel := context.WithCancel(context.Background())
53+
watchCancel = cancel
54+
watchMu.Unlock()
55+
56+
go runWatcher(ctx, repoPath)
57+
}
58+
59+
// runWatcher polls for file modifications using mtimes (no fsnotify dependency).
60+
func runWatcher(ctx context.Context, repoPath string) {
61+
snapshots := snapshotMTimes(repoPath)
62+
63+
var debounceTimer *time.Timer
64+
var debounceMu sync.Mutex
65+
66+
ticker := time.NewTicker(500 * time.Millisecond)
67+
defer ticker.Stop()
68+
69+
for {
70+
select {
71+
case <-ctx.Done():
72+
return
73+
case <-ticker.C:
74+
current := snapshotMTimes(repoPath)
75+
changed := diffSnapshots(snapshots, current)
76+
snapshots = current
77+
78+
if len(changed) == 0 {
79+
continue
80+
}
81+
82+
// Debounce: reset timer on each burst of changes.
83+
debounceMu.Lock()
84+
if debounceTimer != nil {
85+
debounceTimer.Stop()
86+
}
87+
changedCopy := changed
88+
debounceTimer = time.AfterFunc(watchConfig.debounce, func() {
89+
fmt.Printf("\n%s[watch] %d file(s) changed — running tests…%s\n",
90+
colorYellow, len(changedCopy), colorReset)
91+
for _, p := range changedCopy {
92+
rel, _ := filepath.Rel(repoPath, p)
93+
fmt.Printf(" %s%s%s\n", colorDim, rel, colorReset)
94+
}
95+
runWatchTests(repoPath)
96+
})
97+
debounceMu.Unlock()
98+
}
99+
}
100+
}
101+
102+
// snapshotMTimes returns a map of path → mtime for all watched files.
103+
func snapshotMTimes(repoPath string) map[string]time.Time {
104+
result := make(map[string]time.Time)
105+
_ = filepath.Walk(repoPath, func(path string, info os.FileInfo, err error) error {
106+
if err != nil || info == nil {
107+
return nil
108+
}
109+
if info.IsDir() {
110+
// Skip excluded directories.
111+
for _, ex := range watchConfig.exclude {
112+
if strings.Contains(path, ex) {
113+
return filepath.SkipDir
114+
}
115+
}
116+
return nil
117+
}
118+
if !shouldWatch(path) {
119+
return nil
120+
}
121+
result[path] = info.ModTime()
122+
return nil
123+
})
124+
return result
125+
}
126+
127+
// shouldWatch returns true if path passes include/exclude filters.
128+
func shouldWatch(path string) bool {
129+
for _, ex := range watchConfig.exclude {
130+
if strings.Contains(path, ex) {
131+
return false
132+
}
133+
}
134+
if len(watchConfig.include) == 0 {
135+
return true
136+
}
137+
for _, inc := range watchConfig.include {
138+
if strings.HasSuffix(path, inc) {
139+
return true
140+
}
141+
}
142+
return false
143+
}
144+
145+
// diffSnapshots returns paths that are new or have a newer mtime.
146+
func diffSnapshots(old, current map[string]time.Time) []string {
147+
var changed []string
148+
for path, newTime := range current {
149+
if oldTime, ok := old[path]; !ok || newTime.After(oldTime) {
150+
changed = append(changed, path)
151+
}
152+
}
153+
return changed
154+
}
155+
156+
// runWatchTests runs the test command and prints output.
157+
func runWatchTests(repoPath string) {
158+
cmd := exec.Command("go", "test", "./...")
159+
cmd.Dir = repoPath
160+
out, err := cmd.CombinedOutput()
161+
if err != nil {
162+
fmt.Printf("%s[watch] tests FAILED%s\n%s\n", colorRed, colorReset, string(out))
163+
} else {
164+
fmt.Printf("%s[watch] ✓ tests passed%s\n", colorLime, colorReset)
165+
if len(out) > 0 {
166+
fmt.Printf("%s%s%s\n", colorDim, strings.TrimSpace(string(out)), colorReset)
167+
}
168+
}
169+
}

0 commit comments

Comments
 (0)