feat: add prune subcommand, drop archived/stale entries (#1441)
Deploy to GitHub Pages / build (push) Failing after 51s
Deploy to GitHub Pages / deploy (push) Has been skipped
Pull Requests / Weekly QA / test (push) Failing after 1m13s
Broken Links Report / check-links (push) Failing after 45s

* feat: add prune subcommand, drop archived/stale entries, add container-explorer

Add a new `awesome-docker prune` subcommand that removes README entries
whose repository health status matches a configurable set (default:
archived,stale). URLs are read from the local health cache, or from a
markdown report file via --from-report when the cache is outdated.

Apply it against the issue #1439 health report to remove 5 entries
that survived the recent reorg: stitchocker, docker-consul,
blockbridge-docker-volume, docker-explorer, dockdash.

Add google/container-explorer in the Security section as the actively
maintained successor to the now-archived google/docker-explorer.

Co-Authored-By: Claude <noreply@anthropic.com>

* golangci-lint config

* fix: address golangci-lint findings

Fixes errcheck on bufio.Writer.WriteString, gocritic rangeValCopy via
indexed loops with pointer locals, gosec G703 on user-supplied CLI
output path, noctx by switching to exec.CommandContext with a timeout
in the TUI url opener, prealloc in the scorer test, plus fieldalignment
struct reorders and golines line breaks from --fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Julien Bisconti
2026-05-18 23:46:32 +02:00
committed by GitHub
parent 503e5bd7c7
commit 29222bfcb5
23 changed files with 2548 additions and 2803 deletions
+74
View File
@@ -0,0 +1,74 @@
# Sample golangci-lint config. Edit or delete to taste.
# Full reference: https://golangci-lint.run/usage/configuration/
version: "2"
run:
timeout: 5m
linters:
enable:
- bodyclose # checks HTTP response body is closed
- copyloopvar # Go 1.22+ loop var capture
- dupword # catches "the the"
- durationcheck # multiplication of two durations
- embeddedstructfieldcheck
- errcheck # unchecked errors
- errorlint # errors.Is / errors.As hygiene
- forcetypeassert # type assertions without the comma-ok form
- gocritic # opinionated diagnostics
- gosec # security scanner
- govet # stdlib vet
- ineffassign # ineffectual assignments
- intrange # prefer `for range N`
- misspell # spelling
- modernize
- nilerr # `return nil` after checking err != nil
- noctx # http requests without context
- perfsprint # fmt.Sprintf → string concat where safe
- prealloc # slice preallocation hints
- staticcheck # staticcheck.io
- unconvert # redundant type conversions
- unparam # unused function parameters
- unused # dead code
settings:
govet:
enable-all: true
disable:
- shadow
staticcheck:
checks: ["all", "-QF1001", "-QF1002"] # drop quick-fix style nags
gocritic:
enabled-tags: [diagnostic, performance]
disabled-checks:
- hugeParam
gosec:
excludes:
- G104 # covered by errcheck
- G204 # subprocess with variable — allowed in CLI tooling
- G306 # 0600 file perms — CLIs often write executables
exclusions:
generated: lax
presets: [comments, common-false-positives, legacy, std-error-handling]
rules:
# Test code owns its fixtures.
- path: '_test\.go'
linters: [forcetypeassert, dupword, errcheck, gosec, unparam]
# CLI entry point: fmt.Fprint to stdout/stderr is fire-and-forget.
- linters: [errcheck]
source: 'fmt\.Fprint(ln|f)?\('
issues:
max-issues-per-linter: 50
max-same-issues: 10
formatters:
settings:
golines:
max-len: 120
tab-len: 8
enable:
- golines
- gofmt
- goimports
+3 -5
View File
@@ -225,7 +225,6 @@ Signing, attestation, and provenance for container images.
- [plash](https://github.com/ihucos/plash) - A container run and build engine - runs inside docker. - [plash](https://github.com/ihucos/plash) - A container run and build engine - runs inside docker.
- [podman-compose](https://github.com/containers/podman-compose) - A script to run docker-compose.yml using podman. - [podman-compose](https://github.com/containers/podman-compose) - A script to run docker-compose.yml using podman.
- [Smalte](https://github.com/roquie/smalte) Dynamically configure applications that require static configuration in docker container. - [Smalte](https://github.com/roquie/smalte) Dynamically configure applications that require static configuration in docker container.
- [Stitchocker](https://github.com/alexaandrov/stitchocker) - A lightweight and fast command line utility for conveniently grouping your docker-compose multiple container services.
### Orchestration ### Orchestration
@@ -278,7 +277,6 @@ Self-hosted and managed cloud platforms (PaaS/CaaS, deployment automation). Comm
Container networking, overlay networks, DNS/service-discovery bridges. Container networking, overlay networks, DNS/service-discovery bridges.
- [Calico][calico] - Calico is a pure layer 3 virtual network that allows containers over multiple docker-hosts to talk to each other. - [Calico][calico] - Calico is a pure layer 3 virtual network that allows containers over multiple docker-hosts to talk to each other.
- [docker-consul](https://github.com/gliderlabs/docker-consul) - Consul packaged for Docker — registers and discovers running containers.
- [docker-dns](https://github.com/bytesharky/docker-dns) - Lightweight DNS forwarder for Docker containers, resolves container names with custom suffixes (e.g. `.docker`) on the host to simplify service discovery. - [docker-dns](https://github.com/bytesharky/docker-dns) - Lightweight DNS forwarder for Docker containers, resolves container names with custom suffixes (e.g. `.docker`) on the host to simplify service discovery.
- [Flannel](https://github.com/coreos/flannel/) - Flannel is a virtual network that gives a subnet to each host for use with container runtimes. - [Flannel](https://github.com/coreos/flannel/) - Flannel is a virtual network that gives a subnet to each host for use with container runtimes.
- [netshoot](https://github.com/nicolaka/netshoot) - The netshoot container has a powerful set of networking tools to help troubleshoot Docker networking issues. - [netshoot](https://github.com/nicolaka/netshoot) - The netshoot container has a powerful set of networking tools to help troubleshoot Docker networking issues.
@@ -287,6 +285,8 @@ Container networking, overlay networks, DNS/service-discovery bridges.
### Reverse Proxy ### Reverse Proxy
Container-aware reverse proxies, ingress, and TLS-terminating front-ends with auto-discovery.
- [BunkerWeb](https://github.com/bunkerity/bunkerweb) - Open-source and next-gen Web Application Firewall (WAF). - [BunkerWeb](https://github.com/bunkerity/bunkerweb) - Open-source and next-gen Web Application Firewall (WAF).
- [caddy-docker-proxy](https://github.com/lucaslorentz/caddy-docker-proxy) - Caddy-based reverse proxy, configured with service or container labels. - [caddy-docker-proxy](https://github.com/lucaslorentz/caddy-docker-proxy) - Caddy-based reverse proxy, configured with service or container labels.
- [caddy-docker-upstreams](https://github.com/invzhi/caddy-docker-upstreams) - Docker upstreams module for Caddy, configured with container labels. - [caddy-docker-upstreams](https://github.com/invzhi/caddy-docker-upstreams) - Docker upstreams module for Caddy, configured with container labels.
@@ -302,7 +302,6 @@ Container networking, overlay networks, DNS/service-discovery bridges.
## Storage & Data ## Storage & Data
- [Blockbridge](https://github.com/blockbridge/blockbridge-docker-volume) - :yen: The Blockbridge plugin is a volume plugin that provides access to an extensible set of container-based persistent storage options. It supports single and multi-host Docker environments with features that include tenant isolation, automated provisioning, encryption, secure deletion, snapshots and QoS.
- [Label Backup](https://github.com/resulgg/label-backup) - A lightweight, Docker-aware backup agent that automatically discovers and backs up containerized databases (PostgreSQL, MySQL, MongoDB, Redis) based on Docker labels. Supports local storage and S3-compatible destinations with flexible scheduling via cron expressions. - [Label Backup](https://github.com/resulgg/label-backup) - A lightweight, Docker-aware backup agent that automatically discovers and backs up containerized databases (PostgreSQL, MySQL, MongoDB, Redis) based on Docker labels. Supports local storage and S3-compatible destinations with flexible scheduling via cron expressions.
- [Docker Volume Backup](https://github.com/offen/docker-volume-backup) Backup Docker volumes locally or to any S3 compatible storage. - [Docker Volume Backup](https://github.com/offen/docker-volume-backup) Backup Docker volumes locally or to any S3 compatible storage.
- [Netshare](https://github.com/ContainX/docker-volume-netshare) Docker NFS, AWS EFS, Ceph & Samba/CIFS Volume Plugin. - [Netshare](https://github.com/ContainX/docker-volume-netshare) Docker NFS, AWS EFS, Ceph & Samba/CIFS Volume Plugin.
@@ -345,10 +344,10 @@ Container hardening, runtime security, policy, compliance, and forensics. Self-h
- [buildcage](https://github.com/dash14/buildcage) - Restricts outbound network access during Docker builds to prevent supply chain attacks, working as a drop-in BuildKit remote driver for Docker Buildx, with ready-to-use GitHub Actions. - [buildcage](https://github.com/dash14/buildcage) - Restricts outbound network access during Docker builds to prevent supply chain attacks, working as a drop-in BuildKit remote driver for Docker Buildx, with ready-to-use GitHub Actions.
- [CetusGuard](https://github.com/hectorm/cetusguard) - CetusGuard is a tool that protects the Docker daemon socket by filtering calls to its API endpoints. - [CetusGuard](https://github.com/hectorm/cetusguard) - CetusGuard is a tool that protects the Docker daemon socket by filtering calls to its API endpoints.
- [Checkov](https://github.com/bridgecrewio/checkov) - Static analysis for infrastructure as code manifests (Terraform, Kubernetes, Cloudformation, Helm, Dockerfile, Kustomize) find security misconfiguration and fix them. - [Checkov](https://github.com/bridgecrewio/checkov) - Static analysis for infrastructure as code manifests (Terraform, Kubernetes, Cloudformation, Helm, Dockerfile, Kustomize) find security misconfiguration and fix them.
- [container-explorer](https://github.com/google/container-explorer) - Forensic utility to explore Docker and containerd container details from mounted disk images.
- [Deepfence Threat Mapper](https://github.com/deepfence/ThreatMapper) - Powerful runtime vulnerability scanner for kubernetes, virtual machines and serverless. - [Deepfence Threat Mapper](https://github.com/deepfence/ThreatMapper) - Powerful runtime vulnerability scanner for kubernetes, virtual machines and serverless.
- [Den](https://github.com/us/den) - Self-hosted sandbox runtime for AI agents with Docker containers, security hardening, REST API and WebSocket support. - [Den](https://github.com/us/den) - Self-hosted sandbox runtime for AI agents with Docker containers, security hardening, REST API and WebSocket support.
- [docker-bench-security](https://github.com/docker/docker-bench-security) - Script that checks for dozens of common best-practices around deploying Docker containers in production. - [docker-bench-security](https://github.com/docker/docker-bench-security) - Script that checks for dozens of common best-practices around deploying Docker containers in production.
- [docker-explorer](https://github.com/google/docker-explorer) - A tool to help forensicate offline docker acquisitions.
- [docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy) - HAProxy-based fine-grained filter for the Docker API socket; widely used to expose a restricted socket to reverse proxies and homelab stacks. - [docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy) - HAProxy-based fine-grained filter for the Docker API socket; widely used to expose a restricted socket to reverse proxies and homelab stacks.
- [KICS](https://github.com/checkmarx/kics) - An infrastructure-as-code scanning tool, find security vulnerabilities, compliance issues, and infrastructure misconfigurations early in the development cycle. Can be extended for additional policies. - [KICS](https://github.com/checkmarx/kics) - An infrastructure-as-code scanning tool, find security vulnerabilities, compliance issues, and infrastructure misconfigurations early in the development cycle. Can be extended for additional policies.
- [Prisma Cloud](https://www.paloaltonetworks.com/prisma/cloud) - :yen: (Previously Twistlock Security Suite) detects vulnerabilities, hardens container images, and enforces security policies across the lifecycle of applications. - [Prisma Cloud](https://www.paloaltonetworks.com/prisma/cloud) - :yen: (Previously Twistlock Security Suite) detects vulnerabilities, hardens container images, and enforces security policies across the lifecycle of applications.
@@ -377,7 +376,6 @@ TUIs, CLI tools, and shell integrations for Docker.
- [dctl](https://github.com/FabienD/docker-stack) - Dctl is a Cli tool that helps developers by allowing them to execute all docker compose commands anywhere in the terminal, and more. - [dctl](https://github.com/FabienD/docker-stack) - Dctl is a Cli tool that helps developers by allowing them to execute all docker compose commands anywhere in the terminal, and more.
- [decompose](https://github.com/s0rg/decompose) - Reverse-engineering tool for docker environments. - [decompose](https://github.com/s0rg/decompose) - Reverse-engineering tool for docker environments.
- [dive](https://github.com/wagoodman/dive) - A tool for exploring each layer in a docker image. - [dive](https://github.com/wagoodman/dive) - A tool for exploring each layer in a docker image.
- [dockdash](https://github.com/byrnedo/dockdash) - Detailed Docker container stats.
- [docker pushrm](https://github.com/christian-korneck/docker-pushrm) - A Docker CLI plugin that lets you push the README.md file from the current directory to Docker Hub. Also supports Quay and Harbor. - [docker pushrm](https://github.com/christian-korneck/docker-pushrm) - A Docker CLI plugin that lets you push the README.md file from the current directory to Docker Hub. Also supports Quay and Harbor.
- [docker-captain](https://github.com/lucabello/docker-captain) - A friendly CLI to manage multiple Docker Compose deployments with style — powered by Typer, Rich, questionary, and sh. - [docker-captain](https://github.com/lucabello/docker-captain) - A friendly CLI to manage multiple Docker Compose deployments with style — powered by Typer, Rich, questionary, and sh.
- [dockerfile-mode](https://github.com/spotify/dockerfile-mode) - An Emacs mode for handling Dockerfiles. - [dockerfile-mode](https://github.com/spotify/dockerfile-mode) - An Emacs mode for handling Dockerfiles.
+199 -24
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
@@ -13,6 +14,7 @@ import (
"github.com/veggiemonk/awesome-docker/internal/checker" "github.com/veggiemonk/awesome-docker/internal/checker"
"github.com/veggiemonk/awesome-docker/internal/linter" "github.com/veggiemonk/awesome-docker/internal/linter"
"github.com/veggiemonk/awesome-docker/internal/parser" "github.com/veggiemonk/awesome-docker/internal/parser"
"github.com/veggiemonk/awesome-docker/internal/pruner"
"github.com/veggiemonk/awesome-docker/internal/scorer" "github.com/veggiemonk/awesome-docker/internal/scorer"
"github.com/veggiemonk/awesome-docker/internal/tui" "github.com/veggiemonk/awesome-docker/internal/tui"
) )
@@ -27,11 +29,11 @@ const (
) )
type checkSummary struct { type checkSummary struct {
ExternalTotal int
GitHubTotal int
Broken []checker.LinkResult Broken []checker.LinkResult
Redirected []checker.LinkResult Redirected []checker.LinkResult
GitHubErrors []error GitHubErrors []error
ExternalTotal int
GitHubTotal int
GitHubSkipped bool GitHubSkipped bool
} }
@@ -52,6 +54,7 @@ func main() {
validateCmd(), validateCmd(),
ciCmd(), ciCmd(),
browseCmd(), browseCmd(),
pruneCmd(),
) )
if err := root.Execute(); err != nil { if err := root.Execute(); err != nil {
@@ -154,7 +157,7 @@ func runLinkChecks(prMode bool) (checkSummary, error) {
func runHealth(ctx context.Context) error { func runHealth(ctx context.Context) error {
token := os.Getenv("GITHUB_TOKEN") token := os.Getenv("GITHUB_TOKEN")
if token == "" { if token == "" {
return fmt.Errorf("GITHUB_TOKEN environment variable is required") return errors.New("GITHUB_TOKEN environment variable is required")
} }
doc, err := parseReadme() doc, err := parseReadme()
@@ -174,9 +177,12 @@ func runHealth(ctx context.Context) error {
} }
if len(infos) == 0 { if len(infos) == 0 {
if len(errs) > 0 { if len(errs) > 0 {
return fmt.Errorf("failed to fetch GitHub metadata for all repositories (%d errors); check network/DNS and GITHUB_TOKEN", len(errs)) return fmt.Errorf(
"failed to fetch GitHub metadata for all repositories (%d errors); check network/DNS and GITHUB_TOKEN",
len(errs),
)
} }
return fmt.Errorf("no GitHub repositories found in README") return errors.New("no GitHub repositories found in README")
} }
scored := scorer.ScoreAll(infos) scored := scorer.ScoreAll(infos)
@@ -211,11 +217,12 @@ func scoredFromCache() ([]scorer.ScoredEntry, error) {
return nil, fmt.Errorf("load cache: %w", err) return nil, fmt.Errorf("load cache: %w", err)
} }
if len(hc.Entries) == 0 { if len(hc.Entries) == 0 {
return nil, fmt.Errorf("no cache data, run 'health' first") return nil, errors.New("no cache data, run 'health' first")
} }
scored := make([]scorer.ScoredEntry, 0, len(hc.Entries)) scored := make([]scorer.ScoredEntry, 0, len(hc.Entries))
for _, e := range hc.Entries { for i := range hc.Entries {
e := &hc.Entries[i]
scored = append(scored, scorer.ScoredEntry{ scored = append(scored, scorer.ScoredEntry{
URL: e.URL, URL: e.URL,
Name: e.Name, Name: e.Name,
@@ -409,7 +416,11 @@ func checkCmd() *cobra.Command {
} }
} }
if len(summary.Broken) > 0 && len(summary.GitHubErrors) > 0 { if len(summary.Broken) > 0 && len(summary.GitHubErrors) > 0 {
return fmt.Errorf("found %d broken links and %d GitHub API errors", len(summary.Broken), len(summary.GitHubErrors)) return fmt.Errorf(
"found %d broken links and %d GitHub API errors",
len(summary.Broken),
len(summary.GitHubErrors),
)
} }
if len(summary.Broken) > 0 { if len(summary.Broken) > 0 {
return fmt.Errorf("found %d broken links", len(summary.Broken)) return fmt.Errorf("found %d broken links", len(summary.Broken))
@@ -560,20 +571,40 @@ func ciBrokenLinksCmd() *cobra.Command {
} }
} }
if err := writeGitHubOutput(githubOutput, "has_errors", strconv.FormatBool(hasErrors)); err != nil { if err := writeGitHubOutput(
githubOutput,
"has_errors",
strconv.FormatBool(hasErrors),
); err != nil {
return err return err
} }
if err := writeGitHubOutput(githubOutput, "check_exit_code", strconv.Itoa(exitCode)); err != nil { if err := writeGitHubOutput(
githubOutput,
"check_exit_code",
strconv.Itoa(exitCode),
); err != nil {
return err return err
} }
if err := writeGitHubOutput(githubOutput, "broken_count", strconv.Itoa(len(summary.Broken))); err != nil { if err := writeGitHubOutput(
githubOutput,
"broken_count",
strconv.Itoa(len(summary.Broken)),
); err != nil {
return err return err
} }
if err := writeGitHubOutput(githubOutput, "github_error_count", strconv.Itoa(len(summary.GitHubErrors))); err != nil { if err := writeGitHubOutput(
githubOutput,
"github_error_count",
strconv.Itoa(len(summary.GitHubErrors)),
); err != nil {
return err return err
} }
if runErr != nil { if runErr != nil {
if err := writeGitHubOutput(githubOutput, "run_error", sanitizeOutputValue(runErr.Error())); err != nil { if err := writeGitHubOutput(
githubOutput,
"run_error",
sanitizeOutputValue(runErr.Error()),
); err != nil {
return err return err
} }
} }
@@ -582,7 +613,11 @@ func ciBrokenLinksCmd() *cobra.Command {
fmt.Printf("CI broken-links run error: %v\n", runErr) fmt.Printf("CI broken-links run error: %v\n", runErr)
} }
if hasErrors { if hasErrors {
fmt.Printf("CI broken-links found %d broken links and %d GitHub errors\n", len(summary.Broken), len(summary.GitHubErrors)) fmt.Printf(
"CI broken-links found %d broken links and %d GitHub errors\n",
len(summary.Broken),
len(summary.GitHubErrors),
)
} else { } else {
fmt.Println("CI broken-links found no errors") fmt.Println("CI broken-links found no errors")
} }
@@ -592,7 +627,11 @@ func ciBrokenLinksCmd() *cobra.Command {
return runErr return runErr
} }
if hasErrors { if hasErrors {
return fmt.Errorf("found %d broken links and %d GitHub API errors", len(summary.Broken), len(summary.GitHubErrors)) return fmt.Errorf(
"found %d broken links and %d GitHub API errors",
len(summary.Broken),
len(summary.GitHubErrors),
)
} }
} }
return nil return nil
@@ -600,7 +639,8 @@ func ciBrokenLinksCmd() *cobra.Command {
} }
cmd.Flags().StringVar(&issueFile, "issue-file", "broken_links_issue.md", "Path to write issue markdown body") cmd.Flags().StringVar(&issueFile, "issue-file", "broken_links_issue.md", "Path to write issue markdown body")
cmd.Flags().StringVar(&githubOutput, "github-output", "", "Path to GitHub output file (typically $GITHUB_OUTPUT)") cmd.Flags().
StringVar(&githubOutput, "github-output", "", "Path to GitHub output file (typically $GITHUB_OUTPUT)")
cmd.Flags().BoolVar(&strict, "strict", false, "Return non-zero when errors are found") cmd.Flags().BoolVar(&strict, "strict", false, "Return non-zero when errors are found")
return cmd return cmd
} }
@@ -629,25 +669,49 @@ func ciHealthReportCmd() *cobra.Command {
} }
} }
if err := writeGitHubOutput(githubOutput, "has_report", strconv.FormatBool(hasReport)); err != nil { if err := writeGitHubOutput(
githubOutput,
"has_report",
strconv.FormatBool(hasReport),
); err != nil {
return err return err
} }
if err := writeGitHubOutput(githubOutput, "health_ok", strconv.FormatBool(healthOK)); err != nil { if err := writeGitHubOutput(
githubOutput,
"health_ok",
strconv.FormatBool(healthOK),
); err != nil {
return err return err
} }
if err := writeGitHubOutput(githubOutput, "report_ok", strconv.FormatBool(reportOK)); err != nil { if err := writeGitHubOutput(
githubOutput,
"report_ok",
strconv.FormatBool(reportOK),
); err != nil {
return err return err
} }
if err := writeGitHubOutput(githubOutput, "has_errors", strconv.FormatBool(hasErrors)); err != nil { if err := writeGitHubOutput(
githubOutput,
"has_errors",
strconv.FormatBool(hasErrors),
); err != nil {
return err return err
} }
if healthErr != nil { if healthErr != nil {
if err := writeGitHubOutput(githubOutput, "health_error", sanitizeOutputValue(healthErr.Error())); err != nil { if err := writeGitHubOutput(
githubOutput,
"health_error",
sanitizeOutputValue(healthErr.Error()),
); err != nil {
return err return err
} }
} }
if reportErr != nil { if reportErr != nil {
if err := writeGitHubOutput(githubOutput, "report_error", sanitizeOutputValue(reportErr.Error())); err != nil { if err := writeGitHubOutput(
githubOutput,
"report_error",
sanitizeOutputValue(reportErr.Error()),
); err != nil {
return err return err
} }
} }
@@ -677,7 +741,8 @@ func ciHealthReportCmd() *cobra.Command {
} }
cmd.Flags().StringVar(&issueFile, "issue-file", "health_report.txt", "Path to write health issue markdown body") cmd.Flags().StringVar(&issueFile, "issue-file", "health_report.txt", "Path to write health issue markdown body")
cmd.Flags().StringVar(&githubOutput, "github-output", "", "Path to GitHub output file (typically $GITHUB_OUTPUT)") cmd.Flags().
StringVar(&githubOutput, "github-output", "", "Path to GitHub output file (typically $GITHUB_OUTPUT)")
cmd.Flags().BoolVar(&strict, "strict", false, "Return non-zero when health/report fails") cmd.Flags().BoolVar(&strict, "strict", false, "Return non-zero when health/report fails")
return cmd return cmd
} }
@@ -693,7 +758,7 @@ func browseCmd() *cobra.Command {
return fmt.Errorf("load cache: %w", err) return fmt.Errorf("load cache: %w", err)
} }
if len(hc.Entries) == 0 { if len(hc.Entries) == 0 {
return fmt.Errorf("no cache data; run 'awesome-docker health' first") return errors.New("no cache data; run 'awesome-docker health' first")
} }
return tui.Run(hc.Entries) return tui.Run(hc.Entries)
}, },
@@ -701,3 +766,113 @@ func browseCmd() *cobra.Command {
cmd.Flags().StringVar(&cachePath, "cache", healthCachePath, "Path to health cache YAML") cmd.Flags().StringVar(&cachePath, "cache", healthCachePath, "Path to health cache YAML")
return cmd return cmd
} }
func pruneCmd() *cobra.Command {
var (
statuses []string
dryRun bool
keepCache bool
fromReport string
)
cmd := &cobra.Command{
Use: "prune",
Short: "Remove README entries by health status (default: archived, stale)",
Long: `Remove entries from README.md whose repository health status matches the
given list (default: "archived,stale"). The matching cache entries are also
dropped so the next health refresh starts from a clean slate.
By default the target URL list is read from the local health cache. Refresh
it first with:
awesome-docker health
If the local cache is outdated (e.g. you only have the markdown report from
a CI-generated issue), point --from-report at the saved markdown report file
and the URLs will be parsed from its section headings instead.
Use --dry-run to preview what would be removed without writing files.`,
RunE: func(cmd *cobra.Command, args []string) error {
var (
targets map[string]cache.HealthEntry
hc *cache.HealthCache
)
if fromReport != "" {
f, err := os.Open(fromReport) //nolint:gosec
if err != nil {
return fmt.Errorf("open report: %w", err)
}
defer f.Close()
targets, err = pruner.TargetsFromReport(f, statuses)
if err != nil {
return fmt.Errorf("parse report: %w", err)
}
} else {
var err error
hc, err = cache.LoadHealthCache(healthCachePath)
if err != nil {
return fmt.Errorf("load cache: %w", err)
}
if len(hc.Entries) == 0 {
return errors.New("no cache data; run 'awesome-docker health' first")
}
targets = pruner.TargetURLs(hc, statuses)
}
if len(targets) == 0 {
fmt.Printf("No entries match statuses %v; nothing to do\n", statuses)
return nil
}
res, err := pruner.PruneREADME(readmePath, targets, dryRun)
if err != nil {
return fmt.Errorf("prune readme: %w", err)
}
byStatus := map[string]int{}
for _, r := range res.Removed {
byStatus[r.Status]++
}
action := "Removed"
if dryRun {
action = "Would remove"
}
fmt.Printf("%s %d entries from %s\n", action, len(res.Removed), readmePath)
for s, n := range byStatus {
fmt.Printf(" %s: %d\n", s, n)
}
for _, r := range res.Removed {
fmt.Printf(" - [%s] %s (%s)\n", r.Status, r.Name, r.URL)
}
if len(res.NotFound) > 0 {
fmt.Printf(
"\n%d target URLs not found in README (already pruned or URL drift):\n",
len(res.NotFound),
)
for _, u := range res.NotFound {
fmt.Printf(" %s\n", u)
}
}
if !keepCache && hc != nil {
dropped, err := pruner.PruneCache(healthCachePath, hc, targets, dryRun)
if err != nil {
return fmt.Errorf("prune cache: %w", err)
}
cacheAction := "Removed"
if dryRun {
cacheAction = "Would remove"
}
fmt.Printf("\n%s %d cache entries from %s\n", cacheAction, dropped, healthCachePath)
}
return nil
},
}
cmd.Flags().
StringSliceVar(&statuses, "status", []string{"archived", "stale"}, "Comma-separated health statuses to prune (archived,stale,inactive,dead)")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print what would be removed without writing files")
cmd.Flags().BoolVar(&keepCache, "keep-cache", false, "Do not remove pruned URLs from the health cache")
cmd.Flags().
StringVar(&fromReport, "from-report", "", "Read target URLs from a markdown health report file instead of the cache")
return cmd
}
+1711 -2634
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,6 +1,6 @@
module github.com/veggiemonk/awesome-docker module github.com/veggiemonk/awesome-docker
go 1.26.0 go 1.26.3
require ( require (
charm.land/bubbletea/v2 v2.0.6 charm.land/bubbletea/v2 v2.0.6
@@ -14,7 +14,7 @@ require (
require ( require (
github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect
@@ -30,5 +30,5 @@ require (
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.44.0 // indirect
) )
+4
View File
@@ -8,6 +8,8 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac= github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 h1:pxGjlWZFcRQMWAdtjRelpL3Gbu8iYIyuO3Eqbd037Ow=
github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3/go.mod h1:SnKWaPaTnkTNXJgdgdquu66de12V8pW/b/qlTGaF9xg=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
@@ -56,6 +58,8 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+7 -2
View File
@@ -2,8 +2,10 @@ package builder
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
@@ -61,10 +63,13 @@ func Build(markdownPath, templatePath, outputPath string) error {
} }
} }
if !replaced { if !replaced {
return fmt.Errorf("template missing supported markdown placeholder") return errors.New("template missing supported markdown placeholder")
} }
if err := os.WriteFile(outputPath, []byte(output), 0o644); err != nil { // outputPath is a CLI-provided destination for a generated website file;
// the user controls the destination by design, so the taint warning is expected.
//nolint:gosec // G306,G304: user-supplied output path is intentional for this CLI
if err := os.WriteFile(filepath.Clean(outputPath), []byte(output), 0o644); err != nil {
return fmt.Errorf("write output: %w", err) return fmt.Errorf("write output: %w", err)
} }
return nil return nil
+15 -13
View File
@@ -38,17 +38,17 @@ func LoadExcludeList(path string) (*ExcludeList, error) {
// HealthEntry stores metadata about a single entry. // HealthEntry stores metadata about a single entry.
type HealthEntry struct { type HealthEntry struct {
URL string `yaml:"url"` LastPush time.Time `yaml:"last_push,omitempty"`
Name string `yaml:"name"`
Status string `yaml:"status"` // healthy, inactive, stale, archived, dead
Stars int `yaml:"stars,omitempty"`
Forks int `yaml:"forks,omitempty"`
LastPush time.Time `yaml:"last_push,omitempty"`
HasLicense bool `yaml:"has_license,omitempty"`
HasReadme bool `yaml:"has_readme,omitempty"`
CheckedAt time.Time `yaml:"checked_at"` CheckedAt time.Time `yaml:"checked_at"`
URL string `yaml:"url"`
Name string `yaml:"name"`
Status string `yaml:"status"`
Category string `yaml:"category,omitempty"` Category string `yaml:"category,omitempty"`
Description string `yaml:"description,omitempty"` Description string `yaml:"description,omitempty"`
Stars int `yaml:"stars,omitempty"`
Forks int `yaml:"forks,omitempty"`
HasLicense bool `yaml:"has_license,omitempty"`
HasReadme bool `yaml:"has_readme,omitempty"`
} }
// HealthCache is the full YAML cache file. // HealthCache is the full YAML cache file.
@@ -84,15 +84,17 @@ func SaveHealthCache(path string, hc *HealthCache) error {
// Merge updates the cache with new entries, replacing existing ones by URL. // Merge updates the cache with new entries, replacing existing ones by URL.
func (hc *HealthCache) Merge(entries []HealthEntry) { func (hc *HealthCache) Merge(entries []HealthEntry) {
index := make(map[string]int) index := make(map[string]int)
for i, e := range hc.Entries { for i := range hc.Entries {
e := &hc.Entries[i]
index[e.URL] = i index[e.URL] = i
} }
for _, e := range entries { for i := range entries {
if i, exists := index[e.URL]; exists { e := &entries[i]
hc.Entries[i] = e if j, exists := index[e.URL]; exists {
hc.Entries[j] = *e
} else { } else {
index[e.URL] = len(hc.Entries) index[e.URL] = len(hc.Entries)
hc.Entries = append(hc.Entries, e) hc.Entries = append(hc.Entries, *e)
} }
} }
} }
+8 -10
View File
@@ -13,15 +13,15 @@ import (
// RepoInfo holds metadata about a GitHub repository. // RepoInfo holds metadata about a GitHub repository.
type RepoInfo struct { type RepoInfo struct {
PushedAt time.Time
Owner string Owner string
Name string Name string
URL string URL string
Stars int
Forks int
IsArchived bool IsArchived bool
IsDisabled bool IsDisabled bool
IsPrivate bool IsPrivate bool
PushedAt time.Time
Stars int
Forks int
HasLicense bool HasLicense bool
} }
@@ -104,19 +104,17 @@ func NewGitHubChecker(token string) *GitHubChecker {
func (gc *GitHubChecker) CheckRepo(ctx context.Context, owner, name string) (RepoInfo, error) { func (gc *GitHubChecker) CheckRepo(ctx context.Context, owner, name string) (RepoInfo, error) {
var query struct { var query struct {
Repository struct { Repository struct {
PushedAt time.Time
LicenseInfo *struct{ Name string }
StargazerCount int
ForkCount int
IsArchived bool IsArchived bool
IsDisabled bool IsDisabled bool
IsPrivate bool IsPrivate bool
PushedAt time.Time
StargazerCount int
ForkCount int
LicenseInfo *struct {
Name string
}
} `graphql:"repository(owner: $owner, name: $name)"` } `graphql:"repository(owner: $owner, name: $name)"`
} }
vars := map[string]interface{}{ vars := map[string]any{
"owner": githubv4.String(owner), "owner": githubv4.String(owner),
"name": githubv4.String(name), "name": githubv4.String(name),
} }
+8 -1
View File
@@ -34,7 +34,14 @@ func TestExtractGitHubRepo(t *testing.T) {
} }
if ok { if ok {
if owner != tt.owner || name != tt.name { if owner != tt.owner || name != tt.name {
t.Errorf("ExtractGitHubRepo(%q) = (%q, %q), want (%q, %q)", tt.url, owner, name, tt.owner, tt.name) t.Errorf(
"ExtractGitHubRepo(%q) = (%q, %q), want (%q, %q)",
tt.url,
owner,
name,
tt.owner,
tt.name,
)
} }
} }
} }
+3 -3
View File
@@ -18,11 +18,11 @@ const (
// LinkResult holds the result of checking a single URL. // LinkResult holds the result of checking a single URL.
type LinkResult struct { type LinkResult struct {
URL string URL string
OK bool
StatusCode int
Redirected bool
RedirectURL string RedirectURL string
Error string Error string
StatusCode int
OK bool
Redirected bool
} }
func shouldFallbackToGET(statusCode int) bool { func shouldFallbackToGET(statusCode int) bool {
+21 -7
View File
@@ -15,7 +15,9 @@ import (
// by [@author](url), by [@author][ref], by @author // by [@author](url), by [@author][ref], by @author
// //
// Also handles "Created by", "Maintained by" etc. // Also handles "Created by", "Maintained by" etc.
var attributionRe = regexp.MustCompile(`\s+(?:(?:[Cc]reated|[Mm]aintained|[Bb]uilt)\s+)?by\s+\[@[^\]]+\](?:\([^)]*\)|\[[^\]]*\])\.?$`) var attributionRe = regexp.MustCompile(
`\s+(?:(?:[Cc]reated|[Mm]aintained|[Bb]uilt)\s+)?by\s+\[@[^\]]+\](?:\([^)]*\)|\[[^\]]*\])\.?$`,
)
// bareAttributionRe matches: by @author at end of line (no link). // bareAttributionRe matches: by @author at end of line (no link).
var bareAttributionRe = regexp.MustCompile(`\s+by\s+@\w+\.?$`) var bareAttributionRe = regexp.MustCompile(`\s+by\s+@\w+\.?$`)
@@ -136,13 +138,19 @@ func FixFile(path string) (int, error) {
w := bufio.NewWriter(out) w := bufio.NewWriter(out)
for i, line := range lines { for i, line := range lines {
w.WriteString(line) if _, err := w.WriteString(line); err != nil {
return 0, err
}
if i < len(lines)-1 { if i < len(lines)-1 {
w.WriteString("\n") if _, err := w.WriteString("\n"); err != nil {
return 0, err
}
} }
} }
// Preserve trailing newline if original had one // Preserve trailing newline if original had one
w.WriteString("\n") if _, err := w.WriteString("\n"); err != nil {
return 0, err
}
return fixCount, w.Flush() return fixCount, w.Flush()
} }
@@ -218,11 +226,17 @@ func SortFile(path string) (int, error) {
w := bufio.NewWriter(out) w := bufio.NewWriter(out)
for i, line := range lines { for i, line := range lines {
w.WriteString(line) if _, err := w.WriteString(line); err != nil {
return 0, err
}
if i < len(lines)-1 { if i < len(lines)-1 {
w.WriteString("\n") if _, err := w.WriteString("\n"); err != nil {
return 0, err
}
} }
} }
w.WriteString("\n") if _, err := w.WriteString("\n"); err != nil {
return 0, err
}
return fixCount, w.Flush() return fixCount, w.Flush()
} }
+6 -2
View File
@@ -30,9 +30,9 @@ const (
// Issue is a single lint problem found. // Issue is a single lint problem found.
type Issue struct { type Issue struct {
Rule Rule Rule Rule
Message string
Severity Severity Severity Severity
Line int Line int
Message string
} }
func (i Issue) String() string { func (i Issue) String() string {
@@ -79,7 +79,11 @@ func CheckSorted(entries []parser.Entry) []Issue {
Rule: RuleSorted, Rule: RuleSorted,
Severity: SeverityError, Severity: SeverityError,
Line: entries[i].Line, Line: entries[i].Line,
Message: fmt.Sprintf("%q should come before %q (alphabetical order)", entries[i].Name, entries[i-1].Name), Message: fmt.Sprintf(
"%q should come before %q (alphabetical order)",
entries[i].Name,
entries[i-1].Name,
),
}) })
} }
} }
+5 -1
View File
@@ -19,7 +19,11 @@ func TestParseEntry(t *testing.T) {
t.Errorf("url = %q, want %q", entry.URL, "https://www.docker.com/products/docker-desktop/") t.Errorf("url = %q, want %q", entry.URL, "https://www.docker.com/products/docker-desktop/")
} }
if entry.Description != "Official native app. Only for Windows and MacOS." { if entry.Description != "Official native app. Only for Windows and MacOS." {
t.Errorf("description = %q, want %q", entry.Description, "Official native app. Only for Windows and MacOS.") t.Errorf(
"description = %q, want %q",
entry.Description,
"Official native app. Only for Windows and MacOS.",
)
} }
if len(entry.Markers) != 0 { if len(entry.Markers) != 0 {
t.Errorf("markers = %v, want empty", entry.Markers) t.Errorf("markers = %v, want empty", entry.Markers)
+3 -3
View File
@@ -15,17 +15,17 @@ type Entry struct {
Name string Name string
URL string URL string
Description string Description string
Raw string
Markers []Marker Markers []Marker
Line int // 1-based line number in source Line int
Raw string // original line text
} }
// Section is a heading with optional entries and child sections. // Section is a heading with optional entries and child sections.
type Section struct { type Section struct {
Title string Title string
Level int // heading level: 1 = #, 2 = ##, etc.
Entries []Entry Entries []Entry
Children []Section Children []Section
Level int
Line int Line int
} }
+221
View File
@@ -0,0 +1,221 @@
// Package pruner owns the removal of README entries by health status.
//
// Why it exists: maintenance regularly produces a list of archived/stale
// projects (see scorer + cache). Pruner is the seam that translates that list
// into a concrete edit of README.md and config/health_cache.yaml, so the README
// stays in lockstep with the cache instead of drifting via ad-hoc edits.
package pruner
import (
"bufio"
"fmt"
"io"
"os"
"regexp"
"sort"
"strings"
"github.com/veggiemonk/awesome-docker/internal/cache"
"github.com/veggiemonk/awesome-docker/internal/parser"
)
// Removed describes a single entry removed from the README.
type Removed struct {
URL string
Name string
Status string
Line int
}
// Result summarizes a prune run.
type Result struct {
Removed []Removed
// URLs in the target set that didn't appear in the README (already gone,
// non-GitHub indirection, or URL drift between cache and README).
NotFound []string
}
// TargetURLs returns the URL set selected by the given statuses from the cache.
func TargetURLs(hc *cache.HealthCache, statuses []string) map[string]cache.HealthEntry {
want := make(map[string]bool, len(statuses))
for _, s := range statuses {
want[strings.TrimSpace(strings.ToLower(s))] = true
}
out := make(map[string]cache.HealthEntry)
for i := range hc.Entries {
e := &hc.Entries[i]
if want[strings.ToLower(e.Status)] {
out[normalizeURL(e.URL)] = *e
}
}
return out
}
// PruneREADME removes lines whose entry URL is in targets and writes the
// result back to path. If dryRun is true, the file is not modified.
func PruneREADME(path string, targets map[string]cache.HealthEntry, dryRun bool) (Result, error) {
f, err := os.Open(path) //nolint:gosec
if err != nil {
return Result{}, fmt.Errorf("open %s: %w", path, err)
}
lines, err := readLines(f)
f.Close()
if err != nil {
return Result{}, fmt.Errorf("read %s: %w", path, err)
}
var (
kept = make([]string, 0, len(lines))
removed []Removed
hit = make(map[string]bool, len(targets))
)
for i, line := range lines {
entry, perr := parser.ParseEntry(line, i+1)
if perr != nil {
kept = append(kept, line)
continue
}
key := normalizeURL(entry.URL)
meta, ok := targets[key]
if !ok {
kept = append(kept, line)
continue
}
hit[key] = true
removed = append(removed, Removed{
URL: entry.URL,
Name: entry.Name,
Status: meta.Status,
Line: i + 1,
})
}
res := Result{Removed: removed}
for k := range targets {
if !hit[k] {
res.NotFound = append(res.NotFound, targets[k].URL)
}
}
sort.Strings(res.NotFound)
if dryRun || len(removed) == 0 {
return res, nil
}
if err := writeLines(path, kept); err != nil {
return res, fmt.Errorf("write %s: %w", path, err)
}
return res, nil
}
// PruneCache drops entries whose normalized URL is in targets and writes the
// cache back to path. Safe to call when len(targets) == 0 (no-op).
func PruneCache(path string, hc *cache.HealthCache, targets map[string]cache.HealthEntry, dryRun bool) (int, error) {
if len(targets) == 0 {
return 0, nil
}
kept := hc.Entries[:0]
for i := range hc.Entries {
e := &hc.Entries[i]
if _, drop := targets[normalizeURL(e.URL)]; drop {
continue
}
kept = append(kept, *e)
}
dropped := len(hc.Entries) - len(kept)
hc.Entries = kept
if dryRun || dropped == 0 {
return dropped, nil
}
if err := cache.SaveHealthCache(path, hc); err != nil {
return dropped, err
}
return dropped, nil
}
// reportSectionRe matches markdown health-report section headings:
//
// ## Archived (should mark :skull:)
// ## Stale (2+ years inactive)
// ## Inactive (1-2 years)
var reportSectionRe = regexp.MustCompile(`(?i)^##\s+(archived|stale|inactive|dead|healthy)\b`)
// reportEntryRe matches: "- [name](url) - Stars: N - Last push: YYYY-MM-DD"
var reportEntryRe = regexp.MustCompile(`^-\s+\[([^\]]+)\]\((https?://[^)]+)\)`)
// TargetsFromReport parses a markdown health report (same format as the
// `report` subcommand emits) and returns the URL set whose section heading
// matches one of the given statuses.
func TargetsFromReport(r io.Reader, statuses []string) (map[string]cache.HealthEntry, error) {
want := make(map[string]bool, len(statuses))
for _, s := range statuses {
want[strings.TrimSpace(strings.ToLower(s))] = true
}
out := make(map[string]cache.HealthEntry)
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var current string
for sc.Scan() {
line := sc.Text()
if m := reportSectionRe.FindStringSubmatch(line); m != nil {
current = strings.ToLower(m[1])
continue
}
if !want[current] {
continue
}
if m := reportEntryRe.FindStringSubmatch(line); m != nil {
url := strings.TrimSpace(m[2])
out[normalizeURL(url)] = cache.HealthEntry{
URL: url,
Name: m[1],
Status: current,
}
}
}
if err := sc.Err(); err != nil {
return nil, err
}
return out, nil
}
func normalizeURL(u string) string {
u = strings.TrimSpace(u)
u = strings.TrimSuffix(u, "/")
u = strings.ToLower(u)
return u
}
func readLines(r *os.File) ([]string, error) {
var lines []string
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
lines = append(lines, sc.Text())
}
return lines, sc.Err()
}
func writeLines(path string, lines []string) error {
out, err := os.Create(path) //nolint:gosec
if err != nil {
return err
}
defer out.Close()
w := bufio.NewWriter(out)
for i, line := range lines {
if _, err := w.WriteString(line); err != nil {
return err
}
if i < len(lines)-1 {
if err := w.WriteByte('\n'); err != nil {
return err
}
}
}
if err := w.WriteByte('\n'); err != nil {
return err
}
return w.Flush()
}
+173
View File
@@ -0,0 +1,173 @@
package pruner
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/veggiemonk/awesome-docker/internal/cache"
)
func TestTargetURLs(t *testing.T) {
hc := &cache.HealthCache{Entries: []cache.HealthEntry{
{URL: "https://github.com/A/x", Status: "archived"},
{URL: "https://github.com/B/y", Status: "stale"},
{URL: "https://github.com/C/z", Status: "healthy"},
{URL: "https://github.com/D/w", Status: "inactive"},
}}
got := TargetURLs(hc, []string{"archived", "stale"})
if len(got) != 2 {
t.Fatalf("want 2 targets, got %d", len(got))
}
if _, ok := got["https://github.com/a/x"]; !ok {
t.Errorf("expected lowercased URL key for archived entry")
}
}
func TestTargetsFromReport(t *testing.T) {
r := strings.NewReader(`# Health Report
## Summary
- Stale (2+ years): 2
## Archived (should mark :skull:)
- [a/keep](https://github.com/A/Keep) - Stars: 1 - Last push: 2024-01-01
## Stale (2+ years inactive)
- [b/drop](https://github.com/b/drop) - Stars: 2 - Last push: 2020-01-01
## Inactive (1-2 years)
- [c/skip](https://github.com/c/skip) - Stars: 3 - Last push: 2025-01-01
`)
targets, err := TargetsFromReport(r, []string{"archived", "stale"})
if err != nil {
t.Fatal(err)
}
if len(targets) != 2 {
t.Fatalf("want 2, got %d: %v", len(targets), targets)
}
if _, ok := targets["https://github.com/a/keep"]; !ok {
t.Errorf("missing archived entry (case-insensitive)")
}
if _, ok := targets["https://github.com/b/drop"]; !ok {
t.Errorf("missing stale entry")
}
if _, ok := targets["https://github.com/c/skip"]; ok {
t.Errorf("inactive entry should not have been picked up")
}
}
func TestPruneREADME(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "README.md")
content := `# Header
## Tools
- [keep](https://github.com/keep/me) - Healthy project.
- [drop](https://github.com/drop/me) - Stale project.
- [also-keep](https://github.com/also/keep) - Another one.
`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
targets := map[string]cache.HealthEntry{
"https://github.com/drop/me": {URL: "https://github.com/drop/me", Status: "stale"},
}
res, err := PruneREADME(path, targets, false)
if err != nil {
t.Fatal(err)
}
if len(res.Removed) != 1 {
t.Fatalf("want 1 removed, got %d", len(res.Removed))
}
if res.Removed[0].URL != "https://github.com/drop/me" {
t.Errorf("unexpected removed URL: %s", res.Removed[0].URL)
}
out, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(out), "drop/me") {
t.Errorf("expected drop/me to be removed from README, got:\n%s", out)
}
if !strings.Contains(string(out), "keep/me") || !strings.Contains(string(out), "also/keep") {
t.Errorf("expected other entries to be preserved, got:\n%s", out)
}
}
func TestPruneREADMEDryRun(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "README.md")
content := "## X\n\n- [drop](https://github.com/drop/me) - Stale.\n"
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
targets := map[string]cache.HealthEntry{
"https://github.com/drop/me": {URL: "https://github.com/drop/me", Status: "stale"},
}
res, err := PruneREADME(path, targets, true)
if err != nil {
t.Fatal(err)
}
if len(res.Removed) != 1 {
t.Fatalf("want 1 removed (preview), got %d", len(res.Removed))
}
got, _ := os.ReadFile(path)
if string(got) != content {
t.Errorf("dry-run modified file: %q", got)
}
}
func TestPruneREADMENotFound(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "README.md")
if err := os.WriteFile(path, []byte("## X\n\n- [k](https://github.com/k/v) - Keep.\n"), 0o644); err != nil {
t.Fatal(err)
}
targets := map[string]cache.HealthEntry{
"https://github.com/gone/missing": {URL: "https://github.com/gone/missing", Status: "stale"},
}
res, err := PruneREADME(path, targets, false)
if err != nil {
t.Fatal(err)
}
if len(res.Removed) != 0 {
t.Errorf("want 0 removed, got %d", len(res.Removed))
}
if len(res.NotFound) != 1 || res.NotFound[0] != "https://github.com/gone/missing" {
t.Errorf("want gone/missing in NotFound, got %v", res.NotFound)
}
}
func TestPruneCache(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cache.yaml")
hc := &cache.HealthCache{Entries: []cache.HealthEntry{
{URL: "https://github.com/a/keep", Status: "healthy"},
{URL: "https://github.com/b/drop", Status: "stale"},
}}
if err := cache.SaveHealthCache(path, hc); err != nil {
t.Fatal(err)
}
targets := map[string]cache.HealthEntry{
"https://github.com/b/drop": {URL: "https://github.com/b/drop", Status: "stale"},
}
n, err := PruneCache(path, hc, targets, false)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("want 1 dropped, got %d", n)
}
if len(hc.Entries) != 1 || hc.Entries[0].URL != "https://github.com/a/keep" {
t.Errorf("unexpected remaining entries: %v", hc.Entries)
}
}
+13 -10
View File
@@ -23,15 +23,15 @@ const (
// ScoredEntry is a repo with its computed health status. // ScoredEntry is a repo with its computed health status.
type ScoredEntry struct { type ScoredEntry struct {
LastPush time.Time
URL string URL string
Name string Name string
Status Status Status Status
Category string
Description string
Stars int Stars int
Forks int Forks int
HasLicense bool HasLicense bool
LastPush time.Time
Category string
Description string
} }
// ReportSummary contains grouped status counts. // ReportSummary contains grouped status counts.
@@ -46,10 +46,10 @@ type ReportSummary struct {
// ReportData is the full machine-readable report model. // ReportData is the full machine-readable report model.
type ReportData struct { type ReportData struct {
GeneratedAt time.Time `json:"generated_at"` GeneratedAt time.Time `json:"generated_at"`
Total int `json:"total"`
Summary ReportSummary `json:"summary"`
Entries []ScoredEntry `json:"entries"`
ByStatus map[Status][]ScoredEntry `json:"by_status"` ByStatus map[Status][]ScoredEntry `json:"by_status"`
Entries []ScoredEntry `json:"entries"`
Summary ReportSummary `json:"summary"`
Total int `json:"total"`
} }
// Score computes the health status of a GitHub repo. // Score computes the health status of a GitHub repo.
@@ -94,7 +94,8 @@ func ScoreAll(infos []checker.RepoInfo) []ScoredEntry {
func ToCacheEntries(scored []ScoredEntry) []cache.HealthEntry { func ToCacheEntries(scored []ScoredEntry) []cache.HealthEntry {
entries := make([]cache.HealthEntry, len(scored)) entries := make([]cache.HealthEntry, len(scored))
now := time.Now().UTC() now := time.Now().UTC()
for i, s := range scored { for i := range scored {
s := &scored[i]
entries[i] = cache.HealthEntry{ entries[i] = cache.HealthEntry{
URL: s.URL, URL: s.URL,
Name: s.Name, Name: s.Name,
@@ -135,7 +136,8 @@ func GenerateReport(scored []ScoredEntry) string {
return return
} }
fmt.Fprintf(&b, "## %s\n\n", title) fmt.Fprintf(&b, "## %s\n\n", title)
for _, e := range entries { for i := range entries {
e := &entries[i]
fmt.Fprintf(&b, "- [%s](%s) - Stars: %d - Last push: %s\n", fmt.Fprintf(&b, "- [%s](%s) - Stars: %d - Last push: %s\n",
e.Name, e.URL, e.Stars, e.LastPush.Format("2006-01-02")) e.Name, e.URL, e.Stars, e.LastPush.Format("2006-01-02"))
} }
@@ -152,8 +154,9 @@ func GenerateReport(scored []ScoredEntry) string {
// BuildReportData returns full report data for machine-readable and markdown rendering. // BuildReportData returns full report data for machine-readable and markdown rendering.
func BuildReportData(scored []ScoredEntry) ReportData { func BuildReportData(scored []ScoredEntry) ReportData {
groups := map[Status][]ScoredEntry{} groups := map[Status][]ScoredEntry{}
for _, s := range scored { for i := range scored {
groups[s.Status] = append(groups[s.Status], s) s := &scored[i]
groups[s.Status] = append(groups[s.Status], *s)
} }
return ReportData{ return ReportData{
+11 -4
View File
@@ -70,7 +70,13 @@ func TestGenerateReport(t *testing.T) {
results := []ScoredEntry{ results := []ScoredEntry{
{URL: "https://github.com/a/a", Name: "a/a", Status: StatusHealthy, Stars: 100, LastPush: time.Now()}, {URL: "https://github.com/a/a", Name: "a/a", Status: StatusHealthy, Stars: 100, LastPush: time.Now()},
{URL: "https://github.com/b/b", Name: "b/b", Status: StatusArchived, Stars: 50, LastPush: time.Now()}, {URL: "https://github.com/b/b", Name: "b/b", Status: StatusArchived, Stars: 50, LastPush: time.Now()},
{URL: "https://github.com/c/c", Name: "c/c", Status: StatusStale, Stars: 10, LastPush: time.Now().AddDate(-3, 0, 0)}, {
URL: "https://github.com/c/c",
Name: "c/c",
Status: StatusStale,
Stars: 10,
LastPush: time.Now().AddDate(-3, 0, 0),
},
} }
report := GenerateReport(results) report := GenerateReport(results)
if !strings.Contains(report, "Healthy: 1") { if !strings.Contains(report, "Healthy: 1") {
@@ -85,8 +91,9 @@ func TestGenerateReport(t *testing.T) {
} }
func TestGenerateReportShowsAllEntries(t *testing.T) { func TestGenerateReportShowsAllEntries(t *testing.T) {
var results []ScoredEntry const entryCount = 55
for i := 0; i < 55; i++ { results := make([]ScoredEntry, 0, entryCount)
for i := range entryCount {
results = append(results, ScoredEntry{ results = append(results, ScoredEntry{
URL: fmt.Sprintf("https://github.com/stale/%d", i), URL: fmt.Sprintf("https://github.com/stale/%d", i),
Name: fmt.Sprintf("stale/%d", i), Name: fmt.Sprintf("stale/%d", i),
@@ -100,7 +107,7 @@ func TestGenerateReportShowsAllEntries(t *testing.T) {
if strings.Contains(report, "... and") { if strings.Contains(report, "... and") {
t.Fatal("report should not be truncated") t.Fatal("report should not be truncated")
} }
if !strings.Contains(report, "stale/54") { if !strings.Contains(report, fmt.Sprintf("stale/%d", entryCount-1)) {
t.Fatal("report should contain all entries") t.Fatal("report should contain all entries")
} }
} }
+43 -73
View File
@@ -1,10 +1,12 @@
package tui package tui
import ( import (
"context"
"fmt" "fmt"
"os/exec" "os/exec"
"runtime" "runtime"
"strings" "strings"
"time"
"unicode/utf8" "unicode/utf8"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
@@ -19,25 +21,25 @@ const (
panelList panelList
) )
const entryHeight = 5 // lines rendered per entry in the list panel const (
const scrollOff = 4 // minimum lines/entries kept visible above and below cursor entryHeight = 5 // lines rendered per entry in the list panel
scrollOff = 4 // minimum lines/entries kept visible above and below cursor
)
// Model is the top-level Bubbletea model. // Model is the top-level Bubbletea model.
type Model struct { type Model struct {
roots []*TreeNode filterText string
flatTree []FlatNode roots []*TreeNode
flatTree []FlatNode
currentEntries []cache.HealthEntry
activePanel panel activePanel panel
treeCursor int treeCursor int
treeOffset int treeOffset int
listCursor int listCursor int
listOffset int listOffset int
currentEntries []cache.HealthEntry width int
height int
filtering bool filtering bool
filterText string
width, height int
} }
// New creates a new Model from health cache entries. // New creates a new Model from health cache entries.
@@ -135,11 +137,13 @@ func (m *Model) applyFilter() {
query := strings.ToLower(m.filterText) query := strings.ToLower(m.filterText)
var filtered []cache.HealthEntry var filtered []cache.HealthEntry
for _, root := range m.roots { for _, root := range m.roots {
for _, e := range root.AllEntries() { entries := root.AllEntries()
for i := range entries {
e := &entries[i]
if strings.Contains(strings.ToLower(e.Name), query) || if strings.Contains(strings.ToLower(e.Name), query) ||
strings.Contains(strings.ToLower(e.Description), query) || strings.Contains(strings.ToLower(e.Description), query) ||
strings.Contains(strings.ToLower(e.Category), query) { strings.Contains(strings.ToLower(e.Category), query) {
filtered = append(filtered, e) filtered = append(filtered, *e)
} }
} }
} }
@@ -176,10 +180,7 @@ func (m Model) handleTreeKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.updateCurrentEntries() m.updateCurrentEntries()
} }
case "ctrl+d", "pgdown": case "ctrl+d", "pgdown":
half := m.treePanelHeight() / 2 half := max(m.treePanelHeight()/2, 1)
if half < 1 {
half = 1
}
m.treeCursor += half m.treeCursor += half
if m.treeCursor >= len(m.flatTree) { if m.treeCursor >= len(m.flatTree) {
m.treeCursor = len(m.flatTree) - 1 m.treeCursor = len(m.flatTree) - 1
@@ -187,10 +188,7 @@ func (m Model) handleTreeKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.adjustTreeScroll() m.adjustTreeScroll()
m.updateCurrentEntries() m.updateCurrentEntries()
case "ctrl+u", "pgup": case "ctrl+u", "pgup":
half := m.treePanelHeight() / 2 half := max(m.treePanelHeight()/2, 1)
if half < 1 {
half = 1
}
m.treeCursor -= half m.treeCursor -= half
if m.treeCursor < 0 { if m.treeCursor < 0 {
m.treeCursor = 0 m.treeCursor = 0
@@ -233,10 +231,7 @@ func (m Model) handleTreeKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
func (m *Model) adjustTreeScroll() { func (m *Model) adjustTreeScroll() {
visible := m.treePanelHeight() visible := m.treePanelHeight()
off := scrollOff off := min(scrollOff, visible/2)
if off > visible/2 {
off = visible / 2
}
if m.treeCursor < m.treeOffset+off { if m.treeCursor < m.treeOffset+off {
m.treeOffset = m.treeCursor - off m.treeOffset = m.treeCursor - off
} }
@@ -249,10 +244,10 @@ func (m *Model) adjustTreeScroll() {
} }
func (m Model) treePanelHeight() int { func (m Model) treePanelHeight() int {
h := m.height - 6 // header, footer, borders, title h := max(
if h < 1 { // header, footer, borders, title
h = 1 m.height-6, 1,
} )
return h return h
} }
@@ -269,20 +264,14 @@ func (m Model) handleListKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.adjustListScroll() m.adjustListScroll()
} }
case "ctrl+d", "pgdown": case "ctrl+d", "pgdown":
half := m.visibleListEntries() / 2 half := max(m.visibleListEntries()/2, 1)
if half < 1 {
half = 1
}
m.listCursor += half m.listCursor += half
if m.listCursor >= len(m.currentEntries) { if m.listCursor >= len(m.currentEntries) {
m.listCursor = len(m.currentEntries) - 1 m.listCursor = len(m.currentEntries) - 1
} }
m.adjustListScroll() m.adjustListScroll()
case "ctrl+u", "pgup": case "ctrl+u", "pgup":
half := m.visibleListEntries() / 2 half := max(m.visibleListEntries()/2, 1)
if half < 1 {
half = 1
}
m.listCursor -= half m.listCursor -= half
if m.listCursor < 0 { if m.listCursor < 0 {
m.listCursor = 0 m.listCursor = 0
@@ -328,10 +317,7 @@ func (m Model) visibleListEntries() int {
func (m *Model) adjustListScroll() { func (m *Model) adjustListScroll() {
visible := m.visibleListEntries() visible := m.visibleListEntries()
off := scrollOff off := min(scrollOff, visible/2)
if off > visible/2 {
off = visible / 2
}
if m.listCursor < m.listOffset+off { if m.listCursor < m.listOffset+off {
m.listOffset = m.listCursor - off m.listOffset = m.listCursor - off
} }
@@ -345,10 +331,7 @@ func (m *Model) adjustListScroll() {
func (m Model) listPanelHeight() int { func (m Model) listPanelHeight() int {
// height minus header, footer, borders // height minus header, footer, borders
h := m.height - 4 h := max(m.height-4, 1)
if h < 1 {
h = 1
}
return h return h
} }
@@ -406,10 +389,7 @@ func (m Model) renderTree(width, height int) string {
b.WriteString("\n\n") b.WriteString("\n\n")
linesUsed := 2 linesUsed := 2
end := m.treeOffset + height - 2 end := min(m.treeOffset+height-2, len(m.flatTree))
if end > len(m.flatTree) {
end = len(m.flatTree)
}
for i := m.treeOffset; i < end; i++ { for i := m.treeOffset; i < end; i++ {
fn := m.flatTree[i] fn := m.flatTree[i]
if linesUsed >= height { if linesUsed >= height {
@@ -466,16 +446,10 @@ func (m Model) renderList(width, height int) string {
linesUsed := 2 linesUsed := 2
visible := (height - 2) / entryHeight visible := max((height-2)/entryHeight, 1)
if visible < 1 {
visible = 1
}
start := m.listOffset start := m.listOffset
end := start + visible end := min(start+visible, len(m.currentEntries))
if end > len(m.currentEntries) {
end = len(m.currentEntries)
}
for idx := start; idx < end; idx++ { for idx := start; idx < end; idx++ {
if linesUsed+entryHeight > height { if linesUsed+entryHeight > height {
@@ -496,19 +470,16 @@ func (m Model) renderList(width, height int) string {
} }
name := e.Name name := e.Name
statsW := lipgloss.Width(stats) statsW := lipgloss.Width(stats)
maxName := safeWidth - statsW - 2 // 2 for minimum gap maxName := max(
if maxName < 4 { // 2 for minimum gap
maxName = 4 safeWidth-statsW-2, 4,
} )
if lipgloss.Width(name) > maxName { if lipgloss.Width(name) > maxName {
name = truncateToWidth(name, maxName-1) + "…" name = truncateToWidth(name, maxName-1) + "…"
} }
nameStr := entryNameStyle.Render(name) nameStr := entryNameStyle.Render(name)
statsStr := entryDescStyle.Render(stats) statsStr := entryDescStyle.Render(stats)
padding := safeWidth - lipgloss.Width(nameStr) - lipgloss.Width(statsStr) padding := max(safeWidth-lipgloss.Width(nameStr)-lipgloss.Width(statsStr), 1)
if padding < 1 {
padding = 1
}
line1 := nameStr + strings.Repeat(" ", padding) + statsStr line1 := nameStr + strings.Repeat(" ", padding) + statsStr
// Line 2: URL // Line 2: URL
@@ -529,15 +500,12 @@ func (m Model) renderList(width, height int) string {
statusStr := statusStyle(e.Status).Render(e.Status) statusStr := statusStyle(e.Status).Render(e.Status)
lastPush := "" lastPush := ""
if !e.LastPush.IsZero() { if !e.LastPush.IsZero() {
lastPush = fmt.Sprintf(" Last push: %s", e.LastPush.Format("2006-01-02")) lastPush = " Last push: " + e.LastPush.Format("2006-01-02")
} }
line4 := statusStr + entryDescStyle.Render(lastPush) line4 := statusStr + entryDescStyle.Render(lastPush)
// Line 5: separator // Line 5: separator
sepWidth := safeWidth sepWidth := max(safeWidth, 1)
if sepWidth < 1 {
sepWidth = 1
}
line5 := entryDescStyle.Render(strings.Repeat("─", sepWidth)) line5 := entryDescStyle.Render(strings.Repeat("─", sepWidth))
entry := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", line1, line2, line3, line4, line5) entry := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", line1, line2, line3, line4, line5)
@@ -573,14 +541,16 @@ type openURLMsg struct{ err error }
func openURL(url string) tea.Cmd { func openURL(url string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var cmd *exec.Cmd var cmd *exec.Cmd
switch runtime.GOOS { switch runtime.GOOS {
case "darwin": case "darwin":
cmd = exec.Command("open", url) cmd = exec.CommandContext(ctx, "open", url)
case "windows": case "windows":
cmd = exec.Command("cmd", "/c", "start", url) cmd = exec.CommandContext(ctx, "cmd", "/c", "start", url)
default: default:
cmd = exec.Command("xdg-open", url) cmd = exec.CommandContext(ctx, "xdg-open", url)
} }
return openURLMsg{err: cmd.Run()} return openURLMsg{err: cmd.Run()}
} }
+5 -2
View File
@@ -13,8 +13,11 @@ var (
BorderForeground(lipgloss.Color("#555555")) BorderForeground(lipgloss.Color("#555555"))
// Tree styles // Tree styles
treeSelectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FF79C6")).Background(lipgloss.Color("#3B2D50")) treeSelectedStyle = lipgloss.NewStyle().
treeNormalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) Bold(true).
Foreground(lipgloss.Color("#FF79C6")).
Background(lipgloss.Color("#3B2D50"))
treeNormalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC"))
// Entry styles // Entry styles
entryNameStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#50FA7B")) entryNameStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#50FA7B"))
+6 -5
View File
@@ -9,11 +9,11 @@ import (
// TreeNode represents a node in the category tree. // TreeNode represents a node in the category tree.
type TreeNode struct { type TreeNode struct {
Name string // display name (leaf segment, e.g. "Networking") Name string
Path string // full path (e.g. "Container Operations > Networking") Path string
Children []*TreeNode Children []*TreeNode
Expanded bool
Entries []cache.HealthEntry Entries []cache.HealthEntry
Expanded bool
} }
// FlatNode is a visible tree node with its indentation depth. // FlatNode is a visible tree node with its indentation depth.
@@ -51,14 +51,15 @@ func BuildTree(entries []cache.HealthEntry) []*TreeNode {
root := &TreeNode{Name: "root"} root := &TreeNode{Name: "root"}
nodeMap := map[string]*TreeNode{} nodeMap := map[string]*TreeNode{}
for _, e := range entries { for i := range entries {
e := &entries[i]
cat := e.Category cat := e.Category
if cat == "" { if cat == "" {
cat = "Uncategorized" cat = "Uncategorized"
} }
node := ensureNode(root, nodeMap, cat) node := ensureNode(root, nodeMap, cat)
node.Entries = append(node.Entries, e) node.Entries = append(node.Entries, *e)
} }
// Sort children at every level // Sort children at every level
+6 -1
View File
@@ -11,7 +11,12 @@ func TestBuildTree(t *testing.T) {
{URL: "https://github.com/a/b", Name: "a/b", Category: "Projects > Networking", Description: "desc1"}, {URL: "https://github.com/a/b", Name: "a/b", Category: "Projects > Networking", Description: "desc1"},
{URL: "https://github.com/c/d", Name: "c/d", Category: "Projects > Networking", Description: "desc2"}, {URL: "https://github.com/c/d", Name: "c/d", Category: "Projects > Networking", Description: "desc2"},
{URL: "https://github.com/e/f", Name: "e/f", Category: "Projects > Security", Description: "desc3"}, {URL: "https://github.com/e/f", Name: "e/f", Category: "Projects > Security", Description: "desc3"},
{URL: "https://github.com/g/h", Name: "g/h", Category: "Docker Images > Base Tools", Description: "desc4"}, {
URL: "https://github.com/g/h",
Name: "g/h",
Category: "Docker Images > Base Tools",
Description: "desc4",
},
{URL: "https://github.com/i/j", Name: "i/j", Category: "", Description: "no category"}, {URL: "https://github.com/i/j", Name: "i/j", Category: "", Description: "no category"},
} }