From 326bf5d4dd93c468485e8be5836b1930a7003633 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:08:18 +0900 Subject: [PATCH] fix: scan workspace subdirectories for git repos instead of root The workspace itself (~/.picoclaw/workspace) is git-managed but should be excluded. Scan up to 2 levels deep with filepath.Glob to discover project repos like projects/project-A. Return []GitInfo with Name field so the frontend renders each repo separately. Co-Authored-By: Claude Opus 4.6 --- cmd/picoclaw/cmd_gateway.go | 49 +++++++++++++++++++----------- pkg/miniapp/miniapp.go | 3 +- pkg/miniapp/miniapp_test.go | 8 ++--- pkg/miniapp/static/index.html | 56 ++++++++++++++++++++--------------- 4 files changed, 70 insertions(+), 46 deletions(-) diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index bec6ea20a..7a61a6187 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -312,7 +312,7 @@ type agentLoopDataProvider struct { loop *agent.AgentLoop workspace string - gitCache miniapp.GitInfo + gitCache []miniapp.GitInfo gitCacheAt time.Time } @@ -375,30 +375,47 @@ func (p *agentLoopDataProvider) GetActiveSessions() []miniapp.SessionInfo { return result } -func (p *agentLoopDataProvider) GetGitInfo() miniapp.GitInfo { +func (p *agentLoopDataProvider) GetGitInfo() []miniapp.GitInfo { if time.Since(p.gitCacheAt) < gitCacheTTL { return p.gitCache } - info := miniapp.GitInfo{} if p.workspace == "" { - return info + return nil } - // Detect git root from workspace - topOut, err := exec.Command("git", "-C", p.workspace, "rev-parse", "--show-toplevel").Output() - if err != nil { - return info // not a git repo + // Find workspace's own git root to exclude it + workspaceGitRoot := "" + if out, err := exec.Command("git", "-C", p.workspace, "rev-parse", "--show-toplevel").Output(); err == nil { + workspaceGitRoot = strings.TrimSpace(string(out)) } - gitRoot := strings.TrimSpace(string(topOut)) - // Exclude ~/.picoclaw/workspace* (picoclaw's own managed workspace) - home, _ := os.UserHomeDir() - picoDir := filepath.Join(home, ".picoclaw") - if strings.HasPrefix(gitRoot, picoDir) { - return info + // Scan for .git dirs up to 2 levels deep under workspace + seen := map[string]bool{} + var repos []miniapp.GitInfo + for _, pattern := range []string{ + filepath.Join(p.workspace, "*", ".git"), + filepath.Join(p.workspace, "*", "*", ".git"), + } { + matches, _ := filepath.Glob(pattern) + for _, m := range matches { + repoDir := filepath.Dir(m) + if repoDir == workspaceGitRoot || seen[repoDir] { + continue + } + seen[repoDir] = true + repos = append(repos, collectGitRepoInfo(repoDir)) + } } + p.gitCache = repos + p.gitCacheAt = time.Now() + return repos +} + +func collectGitRepoInfo(gitRoot string) miniapp.GitInfo { + info := miniapp.GitInfo{Name: filepath.Base(gitRoot)} + // Current branch out, err := exec.Command("git", "-C", gitRoot, "rev-parse", "--abbrev-ref", "HEAD").Output() if err == nil { @@ -418,7 +435,7 @@ func (p *agentLoopDataProvider) GetGitInfo() miniapp.GitInfo { } } - // Modified/untracked files (git status --porcelain) + // Modified/untracked files out, err = exec.Command("git", "-C", gitRoot, "status", "--porcelain").Output() if err == nil && len(out) > 0 { for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") { @@ -432,8 +449,6 @@ func (p *agentLoopDataProvider) GetGitInfo() miniapp.GitInfo { } } - p.gitCache = info - p.gitCacheAt = time.Now() return info } diff --git a/pkg/miniapp/miniapp.go b/pkg/miniapp/miniapp.go index 199954832..bf5f6e86e 100644 --- a/pkg/miniapp/miniapp.go +++ b/pkg/miniapp/miniapp.go @@ -60,6 +60,7 @@ type SessionInfo struct { // GitInfo represents the git repository state exposed via the API. type GitInfo struct { + Name string `json:"name"` Branch string `json:"branch"` Commits []GitCommit `json:"commits"` Modified []GitChange `json:"modified"` @@ -85,7 +86,7 @@ type DataProvider interface { GetPlanInfo() PlanInfo GetSessionStats() *stats.Stats GetActiveSessions() []SessionInfo - GetGitInfo() GitInfo + GetGitInfo() []GitInfo } // CommandSender injects a command into the message bus on behalf of a user. diff --git a/pkg/miniapp/miniapp_test.go b/pkg/miniapp/miniapp_test.go index d2b8869c3..b2402420d 100644 --- a/pkg/miniapp/miniapp_test.go +++ b/pkg/miniapp/miniapp_test.go @@ -171,8 +171,8 @@ func (m *mockDataProvider) GetSessionStats() *stats.Stats { func (m *mockDataProvider) GetActiveSessions() []SessionInfo { return []SessionInfo{} } -func (m *mockDataProvider) GetGitInfo() GitInfo { - return GitInfo{} +func (m *mockDataProvider) GetGitInfo() []GitInfo { + return nil } type mockSender struct{} @@ -448,8 +448,8 @@ func (m *mutatingDataProvider) GetSessionStats() *stats.Stats { return nil } func (m *mutatingDataProvider) GetActiveSessions() []SessionInfo { return []SessionInfo{} } -func (m *mutatingDataProvider) GetGitInfo() GitInfo { - return GitInfo{} +func (m *mutatingDataProvider) GetGitInfo() []GitInfo { + return nil } // drainEvents reads SSE event lines until it collects `want` distinct event names or times out. diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index c35c3420a..e263d8967 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -1123,38 +1123,46 @@ function loadGit() { renderGitFromData); } -function renderGitFromData(data) { +function renderGitFromData(repos) { var loading = document.getElementById('git-loading'); var el = document.getElementById('git-content'); loading.classList.add('hidden'); el.classList.remove('hidden'); - var html = '
Branch: ' + - escapeHtml(data.branch || 'unknown') + '
'; - - if (data.commits && data.commits.length > 0) { - data.commits.forEach(function(c) { - html += '
' + - '' + escapeHtml(c.hash) + '' + - '' + escapeHtml(c.subject) + '' + - '' + escapeHtml(c.date) + '' + - '
'; - }); - } else { - html += '
No commits found.
'; + if (!repos || repos.length === 0) { + el.innerHTML = '
No git repositories found.
'; + return; } - html += '
'; - if (data.modified && data.modified.length > 0) { - html += '
Changes (' + data.modified.length + ')
'; - data.modified.forEach(function(f) { - html += '
' + - '' + escapeHtml(f.status) + '' + - '' + escapeHtml(f.path) + '' + - '
'; - }); + var html = ''; + repos.forEach(function(repo) { + html += '
' + + escapeHtml(repo.name) + ' — ' + escapeHtml(repo.branch || '?') + '
'; + + if (repo.modified && repo.modified.length > 0) { + html += '
Changes (' + repo.modified.length + ')
'; + repo.modified.forEach(function(f) { + html += '
' + + '' + escapeHtml(f.status) + '' + + '' + escapeHtml(f.path) + '' + + '
'; + }); + } + + if (repo.commits && repo.commits.length > 0) { + html += '
Commits
'; + repo.commits.forEach(function(c) { + html += '
' + + '' + escapeHtml(c.hash) + '' + + '' + escapeHtml(c.subject) + '' + + '' + escapeHtml(c.date) + '' + + '
'; + }); + } else { + html += '
No commits found.
'; + } html += '
'; - } + }); el.innerHTML = html; }