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 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-22 16:08:18 +09:00
parent 30474536d7
commit 326bf5d4dd
4 changed files with 70 additions and 46 deletions

View file

@ -312,7 +312,7 @@ type agentLoopDataProvider struct {
loop *agent.AgentLoop loop *agent.AgentLoop
workspace string workspace string
gitCache miniapp.GitInfo gitCache []miniapp.GitInfo
gitCacheAt time.Time gitCacheAt time.Time
} }
@ -375,30 +375,47 @@ func (p *agentLoopDataProvider) GetActiveSessions() []miniapp.SessionInfo {
return result return result
} }
func (p *agentLoopDataProvider) GetGitInfo() miniapp.GitInfo { func (p *agentLoopDataProvider) GetGitInfo() []miniapp.GitInfo {
if time.Since(p.gitCacheAt) < gitCacheTTL { if time.Since(p.gitCacheAt) < gitCacheTTL {
return p.gitCache return p.gitCache
} }
info := miniapp.GitInfo{}
if p.workspace == "" { if p.workspace == "" {
return info return nil
} }
// Detect git root from workspace // Find workspace's own git root to exclude it
topOut, err := exec.Command("git", "-C", p.workspace, "rev-parse", "--show-toplevel").Output() workspaceGitRoot := ""
if err != nil { if out, err := exec.Command("git", "-C", p.workspace, "rev-parse", "--show-toplevel").Output(); err == nil {
return info // not a git repo workspaceGitRoot = strings.TrimSpace(string(out))
} }
gitRoot := strings.TrimSpace(string(topOut))
// Exclude ~/.picoclaw/workspace* (picoclaw's own managed workspace) // Scan for .git dirs up to 2 levels deep under workspace
home, _ := os.UserHomeDir() seen := map[string]bool{}
picoDir := filepath.Join(home, ".picoclaw") var repos []miniapp.GitInfo
if strings.HasPrefix(gitRoot, picoDir) { for _, pattern := range []string{
return info 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 // Current branch
out, err := exec.Command("git", "-C", gitRoot, "rev-parse", "--abbrev-ref", "HEAD").Output() out, err := exec.Command("git", "-C", gitRoot, "rev-parse", "--abbrev-ref", "HEAD").Output()
if err == nil { 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() out, err = exec.Command("git", "-C", gitRoot, "status", "--porcelain").Output()
if err == nil && len(out) > 0 { if err == nil && len(out) > 0 {
for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") { 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 return info
} }

View file

@ -60,6 +60,7 @@ type SessionInfo struct {
// GitInfo represents the git repository state exposed via the API. // GitInfo represents the git repository state exposed via the API.
type GitInfo struct { type GitInfo struct {
Name string `json:"name"`
Branch string `json:"branch"` Branch string `json:"branch"`
Commits []GitCommit `json:"commits"` Commits []GitCommit `json:"commits"`
Modified []GitChange `json:"modified"` Modified []GitChange `json:"modified"`
@ -85,7 +86,7 @@ type DataProvider interface {
GetPlanInfo() PlanInfo GetPlanInfo() PlanInfo
GetSessionStats() *stats.Stats GetSessionStats() *stats.Stats
GetActiveSessions() []SessionInfo GetActiveSessions() []SessionInfo
GetGitInfo() GitInfo GetGitInfo() []GitInfo
} }
// CommandSender injects a command into the message bus on behalf of a user. // CommandSender injects a command into the message bus on behalf of a user.

View file

@ -171,8 +171,8 @@ func (m *mockDataProvider) GetSessionStats() *stats.Stats {
func (m *mockDataProvider) GetActiveSessions() []SessionInfo { func (m *mockDataProvider) GetActiveSessions() []SessionInfo {
return []SessionInfo{} return []SessionInfo{}
} }
func (m *mockDataProvider) GetGitInfo() GitInfo { func (m *mockDataProvider) GetGitInfo() []GitInfo {
return GitInfo{} return nil
} }
type mockSender struct{} type mockSender struct{}
@ -448,8 +448,8 @@ func (m *mutatingDataProvider) GetSessionStats() *stats.Stats { return nil }
func (m *mutatingDataProvider) GetActiveSessions() []SessionInfo { func (m *mutatingDataProvider) GetActiveSessions() []SessionInfo {
return []SessionInfo{} return []SessionInfo{}
} }
func (m *mutatingDataProvider) GetGitInfo() GitInfo { func (m *mutatingDataProvider) GetGitInfo() []GitInfo {
return GitInfo{} return nil
} }
// drainEvents reads SSE event lines until it collects `want` distinct event names or times out. // drainEvents reads SSE event lines until it collects `want` distinct event names or times out.

View file

@ -1123,38 +1123,46 @@ function loadGit() {
renderGitFromData); renderGitFromData);
} }
function renderGitFromData(data) { function renderGitFromData(repos) {
var loading = document.getElementById('git-loading'); var loading = document.getElementById('git-loading');
var el = document.getElementById('git-content'); var el = document.getElementById('git-content');
loading.classList.add('hidden'); loading.classList.add('hidden');
el.classList.remove('hidden'); el.classList.remove('hidden');
var html = '<div class="card glass"><div class="card-title">Branch: ' + if (!repos || repos.length === 0) {
escapeHtml(data.branch || 'unknown') + '</div>'; el.innerHTML = '<div class="empty-state">No git repositories found.</div>';
return;
if (data.commits && data.commits.length > 0) {
data.commits.forEach(function(c) {
html += '<div class="git-commit">' +
'<span class="git-hash">' + escapeHtml(c.hash) + '</span>' +
'<span class="git-subject">' + escapeHtml(c.subject) + '</span>' +
'<span class="git-meta">' + escapeHtml(c.date) + '</span>' +
'</div>';
});
} else {
html += '<div style="padding:12px;color:var(--hint)">No commits found.</div>';
} }
html += '</div>';
if (data.modified && data.modified.length > 0) { var html = '';
html += '<div class="card glass" style="margin-top:12px"><div class="card-title">Changes (' + data.modified.length + ')</div>'; repos.forEach(function(repo) {
data.modified.forEach(function(f) { html += '<div class="card glass"><div class="card-title">' +
html += '<div class="git-commit">' + escapeHtml(repo.name) + ' &mdash; ' + escapeHtml(repo.branch || '?') + '</div>';
'<span class="git-status git-status-' + (f.status === '??' ? 'u' : f.status.toLowerCase()) + '">' + escapeHtml(f.status) + '</span>' +
'<span class="git-subject">' + escapeHtml(f.path) + '</span>' + if (repo.modified && repo.modified.length > 0) {
'</div>'; html += '<div style="padding:4px 12px 8px;font-size:12px;color:var(--hint)">Changes (' + repo.modified.length + ')</div>';
}); repo.modified.forEach(function(f) {
html += '<div class="git-commit">' +
'<span class="git-status git-status-' + (f.status === '??' ? 'u' : f.status.toLowerCase()) + '">' + escapeHtml(f.status) + '</span>' +
'<span class="git-subject">' + escapeHtml(f.path) + '</span>' +
'</div>';
});
}
if (repo.commits && repo.commits.length > 0) {
html += '<div style="padding:4px 12px 8px;font-size:12px;color:var(--hint)">Commits</div>';
repo.commits.forEach(function(c) {
html += '<div class="git-commit">' +
'<span class="git-hash">' + escapeHtml(c.hash) + '</span>' +
'<span class="git-subject">' + escapeHtml(c.subject) + '</span>' +
'<span class="git-meta">' + escapeHtml(c.date) + '</span>' +
'</div>';
});
} else {
html += '<div style="padding:12px;color:var(--hint)">No commits found.</div>';
}
html += '</div>'; html += '</div>';
} });
el.innerHTML = html; el.innerHTML = html;
} }