Skip to content

Commit ddec066

Browse files
authored
Merge branch 'main' into adopt
2 parents bdef57c + 451785c commit ddec066

58 files changed

Lines changed: 1582 additions & 121 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,40 @@
11
# Changelog
22

3+
## [0.20.15] - 2026-06-14
4+
5+
### Bug Fixes
6+
7+
- **Git branch refreshes now fail visibly when fetch fails** — the dashboard no longer serves stale remote branches or continues a checkout after `git fetch` fails. Branch listing and checkout requests now report the fetch failure so users can fix connectivity, authentication, or remote problems before switching branches.
8+
- **Source URL edits keep metadata and remotes in sync** — updating a tracked skill or agent source now updates the Git remote before saving metadata. If the remote update fails, the API returns an error and leaves the existing source metadata unchanged instead of reporting success with an old on-disk remote.
9+
- **Target removal preserves config when cleanup fails** — removing a target from the dashboard now stops if Skillshare cannot inspect the target, remove the target symlink, remove the target manifest, or clean managed symlinks. The target remains configured so users can fix the filesystem issue and retry instead of losing the target entry.
10+
- **Version checks handle release tag formats correctly** — update checks now accept versions with a leading `v` prefix while still rejecting malformed version segments. Local metadata builds only advertise a release version when built from a clean exact tag; non-release builds stay in `dev` mode so update checks do not compare against commit-describe strings.
11+
- **JSON-mode automation stays clean during cleanup warnings** — temporary Git clone cleanup failures are still logged for human-readable flows, but cleanup warnings no longer leak into `--json` stderr output.
12+
- **Skill linting reports rule load failures instead of panicking** — malformed embedded lint rules now return explicit errors through analysis/discovery paths, and repeated lint runs keep the load error instead of losing it after the first attempt.
13+
- **Audit finding severity dots are vertically centered** — severity indicators in the dashboard Audit findings list now align with their badges and messages.
14+
15+
## [0.20.14] - 2026-06-13
16+
17+
### Bug Fixes
18+
19+
- **Push failures redact token-auth URLs without losing diagnostics** — failed Git push flows now sanitize credential-bearing error output before it reaches CLI/API/UI callers, while still preserving useful Git and pre-push hook diagnostics. Refs: #214.
20+
21+
## [0.20.13] - 2026-06-11
22+
23+
### New Features
24+
25+
#### Web Dashboard
26+
27+
- **Rehydrate missing tracked repos from the dashboard** — the Updates page now shows a warning banner listing tracked repos declared in `.metadata.json` whose clone directories are missing on disk, with a one-click **Rehydrate** button that re-clones them from metadata. The Dashboard's **Update All** also warns about missing repos and points to rehydrate, instead of reporting that there is nothing to update. Refs: #212.
28+
29+
### Bug Fixes
30+
31+
- **`update --all` reports missing tracked repos in batch and project mode** — reporting a missing tracked repo previously only worked when it was the single update target; when `update --all` covered multiple items (the common case) the batch path skipped missing repos silently, and project mode (`-p`) never detected them at all. Both now surface each missing repo with a warning and a one-shot rehydrate hint:
32+
```bash
33+
skillshare update --all # ! _team-skills clone directory absent
34+
skillshare install # rehydrate from metadata
35+
```
36+
`update --all --json` now carries an aggregated `missing_tracked_repos` summary (names + hint), and the per-item error is the concise `clone directory absent`. Refs: #212.
37+
338
## [0.20.12] - 2026-06-11
439

540
### New Features

cmd/skillshare/json_output.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package main
33
import (
44
"encoding/json"
55
"fmt"
6+
"io"
7+
"log/slog"
68
"os"
79
"reflect"
810
"slices"
@@ -80,9 +82,10 @@ func (s *jsonUISuppressor) Flush() {
8082
}
8183

8284
// suppressUIToDevnull temporarily redirects os.Stdout and the progress
83-
// writer to /dev/null so that handler functions using fmt.Printf / ui.*
84-
// produce zero visible output. This keeps --json output clean even when
85-
// stdout and stderr share the same terminal (e.g. docker exec).
85+
// writer to /dev/null and silences slog so that handler functions using
86+
// fmt.Printf / ui.* / slog.* produce zero visible output. This keeps --json
87+
// output clean even when stdout and stderr share the same terminal (e.g.
88+
// docker exec).
8689
// Returns a restore function that MUST be called before writing JSON.
8790
func suppressUIToDevnull() func() {
8891
devnull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
@@ -97,10 +100,14 @@ func suppressUIToDevnull() func() {
97100
ui.SetProgressWriter(devnull)
98101
ui.SuppressProgress()
99102

103+
prevLogger := slog.Default()
104+
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
105+
100106
return func() {
101107
os.Stdout = origStdout
102108
ui.SetProgressWriter(prevProgress)
103109
ui.RestoreProgress()
110+
slog.SetDefault(prevLogger)
104111
if devnull != os.Stderr {
105112
devnull.Close()
106113
}

cmd/skillshare/json_output_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import (
55
"encoding/json"
66
"errors"
77
"fmt"
8+
"log/slog"
89
"os"
10+
"strings"
911
"testing"
1012
)
1113

@@ -90,3 +92,39 @@ func TestWriteJSONErrorIsWrappedWithErrorsAs(t *testing.T) {
9092
t.Fatal("expected wrapped error unwrap chain to include jsonSilentError")
9193
}
9294
}
95+
96+
func TestJSONUISuppressorSuppressesSlogUntilFlush(t *testing.T) {
97+
tmpFile, err := os.CreateTemp("", "skillshare-json-stderr-")
98+
if err != nil {
99+
t.Fatalf("creating temp file: %v", err)
100+
}
101+
t.Cleanup(func() { os.Remove(tmpFile.Name()) })
102+
103+
oldStderr := os.Stderr
104+
os.Stderr = tmpFile
105+
t.Cleanup(func() { os.Stderr = oldStderr })
106+
107+
oldLogger := slog.Default()
108+
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})))
109+
t.Cleanup(func() { slog.SetDefault(oldLogger) })
110+
111+
suppressor := newJSONUISuppressor(true)
112+
slog.Warn("structured cleanup warning")
113+
suppressor.Flush()
114+
slog.Warn("restored warning")
115+
116+
if err := tmpFile.Close(); err != nil {
117+
t.Fatalf("closing temp file: %v", err)
118+
}
119+
data, err := os.ReadFile(tmpFile.Name())
120+
if err != nil {
121+
t.Fatalf("reading captured stderr: %v", err)
122+
}
123+
got := string(data)
124+
if strings.Contains(got, "structured cleanup warning") {
125+
t.Fatalf("structured-mode slog warning leaked to stderr: %q", got)
126+
}
127+
if !strings.Contains(got, "restored warning") {
128+
t.Fatalf("expected logger to be restored after Flush, got: %q", got)
129+
}
130+
}

cmd/skillshare/tracked_metadata.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,34 @@ import (
55
"os"
66
)
77

8+
// missingTrackedRepoShortReason is the concise per-item reason used in summarized
9+
// output (JSON items, UI). The full rehydration hint is surfaced once via
10+
// missingTrackedRepoHint instead of being repeated on every item.
11+
const missingTrackedRepoShortReason = "clone directory absent"
12+
13+
// missingTrackedRepoHint is the one-shot recovery instruction for missing
14+
// tracked repos, shown alongside the summarized list.
15+
func missingTrackedRepoHint() string {
16+
return "Run 'skillshare install' to rehydrate tracked repositories"
17+
}
18+
819
func missingTrackedRepoMessage(name string) string {
9-
return fmt.Sprintf("tracked repository clone is missing: %s (metadata exists but directory is absent). Run 'skillshare install' to rehydrate tracked repositories.", name)
20+
return fmt.Sprintf("tracked repository clone is missing: %s (metadata exists but directory is absent). %s.", name, missingTrackedRepoHint())
21+
}
22+
23+
// missingTrackedRepoResult returns a skipped updateResult for a tracked repo
24+
// whose clone directory is absent on disk, and true. If the directory exists it
25+
// returns the zero value and false. Shared by the global and project single-target
26+
// update paths so both report missing repos consistently (issue #212).
27+
func missingTrackedRepoResult(t updateTarget) (updateResult, bool) {
28+
if _, err := os.Stat(t.path); os.IsNotExist(err) {
29+
return updateResult{
30+
skipped: 1,
31+
items: []updateJSONItem{{Name: t.name, Type: "repo", Status: "skipped", Error: missingTrackedRepoShortReason}},
32+
missingTrackedRepos: []string{t.name},
33+
}, true
34+
}
35+
return updateResult{}, false
1036
}
1137

1238
func updateTrackedRepoErrorMessage(target updateTarget, err error) string {

cmd/skillshare/update.go

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,22 @@ type updateOptions struct {
3131

3232
// updateJSONOutput is the JSON representation for update --json output.
3333
type updateJSONOutput struct {
34-
Updated int `json:"updated"`
35-
Skipped int `json:"skipped"`
36-
SecurityFailed int `json:"security_failed"`
37-
Pruned int `json:"pruned"`
38-
DryRun bool `json:"dry_run"`
39-
Duration string `json:"duration"`
40-
Items []updateJSONItem `json:"items"`
34+
Updated int `json:"updated"`
35+
Skipped int `json:"skipped"`
36+
SecurityFailed int `json:"security_failed"`
37+
Pruned int `json:"pruned"`
38+
DryRun bool `json:"dry_run"`
39+
Duration string `json:"duration"`
40+
Items []updateJSONItem `json:"items"`
41+
MissingTrackedRepos *missingTrackedReposSummary `json:"missing_tracked_repos,omitempty"`
42+
}
43+
44+
// missingTrackedReposSummary aggregates tracked repos declared in metadata whose
45+
// clone directories are absent on disk, so the per-item error stays concise while
46+
// the recovery hint is surfaced once. See issue #212.
47+
type missingTrackedReposSummary struct {
48+
Names []string `json:"names"`
49+
Hint string `json:"hint"`
4150
}
4251

4352
type updateJSONItem struct {
@@ -380,13 +389,8 @@ func cmdUpdate(args []string) error {
380389
var r updateResult
381390
var updateErr error
382391
if t.isRepo {
383-
if opts.all {
384-
if _, statErr := os.Stat(t.path); os.IsNotExist(statErr) {
385-
r.skipped = 1
386-
r.items = append(r.items, updateJSONItem{Name: t.name, Type: "repo", Status: "skipped", Error: missingTrackedRepoMessage(t.name)})
387-
} else {
388-
r, updateErr = updateTrackedRepo(uc, t.name)
389-
}
392+
if mr, missing := missingTrackedRepoResult(t); opts.all && missing {
393+
r = mr
390394
} else {
391395
r, updateErr = updateTrackedRepo(uc, t.name)
392396
}
@@ -404,6 +408,7 @@ func cmdUpdate(args []string) error {
404408
if opts.jsonOutput {
405409
return jsonWriteResult(&r, updateErr)
406410
}
411+
displayMissingTrackedReposWarning(r.missingTrackedRepos)
407412
return updateErr
408413
}
409414

@@ -444,6 +449,12 @@ func updateOutputJSON(result *updateResult, dryRun bool, start time.Time, update
444449
output.SecurityFailed = result.securityFailed
445450
output.Pruned = result.pruned
446451
output.Items = result.items
452+
if len(result.missingTrackedRepos) > 0 {
453+
output.MissingTrackedRepos = &missingTrackedReposSummary{
454+
Names: result.missingTrackedRepos,
455+
Hint: missingTrackedRepoHint(),
456+
}
457+
}
447458
}
448459
return writeJSONResult(&output, updateErr)
449460
}

cmd/skillshare/update_audit_render.go

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@ import (
1111

1212
// updateResult tracks the result of an update operation
1313
type updateResult struct {
14-
updated int
15-
skipped int
16-
securityFailed int
17-
pruned int
18-
items []updateJSONItem
14+
updated int
15+
skipped int
16+
securityFailed int
17+
pruned int
18+
items []updateJSONItem
19+
missingTrackedRepos []string // names of tracked repos declared in metadata but absent on disk
1920
}
2021

2122
// batchBlockedEntry records a skill that was blocked by security audit during batch update.
@@ -232,3 +233,17 @@ func displayStaleWarning(stale []string) {
232233
}
233234
ui.Info("Run with --prune to remove stale skills")
234235
}
236+
237+
// displayMissingTrackedReposWarning warns about tracked repos declared in
238+
// metadata whose clone directories are absent on disk (issue #212).
239+
func displayMissingTrackedReposWarning(missing []string) {
240+
if len(missing) == 0 {
241+
return
242+
}
243+
fmt.Println()
244+
ui.Warning("%d tracked repo(s) declared in metadata but missing on disk:", len(missing))
245+
for _, name := range missing {
246+
ui.ListItem("warning", name, missingTrackedRepoShortReason)
247+
}
248+
ui.Info("%s", missingTrackedRepoHint())
249+
}

cmd/skillshare/update_batch.go

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

33
import (
44
"fmt"
5+
"os"
56
"strings"
67
"time"
78

@@ -61,6 +62,7 @@ func executeBatchUpdate(uc *updateContext, targets []updateTarget) (updateResult
6162
var blockedEntries []batchBlockedEntry
6263
var staleNames []string
6364
var prunedNames []string
65+
var missingRepoNames []string
6466

6567
// Group skills by RepoURL to optimize updates
6668
repoGroups := make(map[string][]updateTarget)
@@ -105,6 +107,15 @@ func executeBatchUpdate(uc *updateContext, targets []updateTarget) (updateResult
105107
}
106108
for _, t := range trackedRepos {
107109
progressBar.UpdateTitle(fmt.Sprintf("Updating %s", t.name))
110+
// Tracked repo declared in metadata but clone dir is absent (issue #212):
111+
// surface it instead of silently skipping (mirrors the single-target path).
112+
if _, statErr := os.Stat(t.path); os.IsNotExist(statErr) {
113+
result.skipped++
114+
missingRepoNames = append(missingRepoNames, t.name)
115+
result.items = append(result.items, updateJSONItem{Name: t.name, Type: "repo", Status: "skipped", Error: missingTrackedRepoShortReason})
116+
progressBar.Increment()
117+
continue
118+
}
108119
updated, auditResult, err := updateTrackedRepoQuick(uc, t.path)
109120
if err != nil {
110121
if isSecurityError(err) {
@@ -125,6 +136,7 @@ func executeBatchUpdate(uc *updateContext, targets []updateTarget) (updateResult
125136
}
126137
progressBar.Increment()
127138
}
139+
result.missingTrackedRepos = missingRepoNames
128140

129141
// Phase 2: grouped skills (one clone per repo)
130142
if len(repoGroups) > 0 {
@@ -276,6 +288,7 @@ func executeBatchUpdate(uc *updateContext, targets []updateTarget) (updateResult
276288
displayUpdateBlockedSection(blockedEntries)
277289
displayPrunedSection(prunedNames)
278290
displayStaleWarning(staleNames)
291+
displayMissingTrackedReposWarning(missingRepoNames)
279292
displayUpdateAuditResults(auditEntries, uc.opts.auditVerbose)
280293
ui.UpdateSummary(ui.UpdateStats{
281294
Updated: result.updated,

cmd/skillshare/update_project.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,20 @@ func updateAllProjectSkills(uc *updateContext) (*updateResult, error) {
230230
return nil, fmt.Errorf("failed to scan skills: %w", err)
231231
}
232232

233+
// Tracked repos declared in metadata but absent on disk (issue #212):
234+
// surface them instead of silently ignoring (mirrors global --all).
235+
existing := make(map[string]bool, len(targets))
236+
for _, t := range targets {
237+
existing[t.name] = true
238+
}
239+
missingRepos, _ := install.GetMissingTrackedRepos(uc.sourcePath)
240+
for _, repo := range missingRepos {
241+
if !existing[repo.Name] {
242+
existing[repo.Name] = true
243+
targets = append(targets, updateTarget{name: repo.Name, path: filepath.Join(uc.sourcePath, filepath.FromSlash(repo.Name)), isRepo: true})
244+
}
245+
}
246+
233247
var repoCount, skillCount int
234248
for _, t := range targets {
235249
if t.isRepo {
@@ -252,7 +266,12 @@ func updateAllProjectSkills(uc *updateContext) (*updateResult, error) {
252266
var r updateResult
253267
var updateErr error
254268
if t.isRepo {
255-
r, updateErr = updateTrackedRepo(uc, t.name)
269+
if mr, missing := missingTrackedRepoResult(t); missing {
270+
r = mr
271+
displayMissingTrackedReposWarning(r.missingTrackedRepos)
272+
} else {
273+
r, updateErr = updateTrackedRepo(uc, t.name)
274+
}
256275
} else {
257276
r, updateErr = updateRegularSkill(uc, t.name)
258277
}

internal/git/info.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -495,16 +495,21 @@ func PushRemoteWithAuth(dir string) error {
495495
}
496496

497497
// PushRemoteWithEnv pushes to the default remote with additional environment
498-
// variables.
498+
// variables. Error output is sanitized of credential values via WrapGitError.
499499
func PushRemoteWithEnv(dir string, extraEnv []string) error {
500500
cmd := exec.Command("git", "push")
501501
cmd.Dir = dir
502502
if len(extraEnv) > 0 {
503503
cmd.Env = append(os.Environ(), extraEnv...)
504504
}
505-
out, err := cmd.CombinedOutput()
505+
506+
var outBuf bytes.Buffer
507+
cmd.Stdout = &outBuf
508+
cmd.Stderr = &outBuf
509+
510+
err := cmd.Run()
506511
if err != nil {
507-
return fmt.Errorf("git push failed: %s", strings.TrimSpace(string(out)))
512+
return install.WrapGitError(outBuf.String(), err, install.UsedTokenAuth(extraEnv))
508513
}
509514
return nil
510515
}

0 commit comments

Comments
 (0)