diff --git a/README.md b/README.md
index f50ec18..fce0104 100644
--- a/README.md
+++ b/README.md
@@ -4,10 +4,22 @@
Rustlings‑style Go exercises in a tiny CLI.
+
+

+
+
### Why this exists
This project is my attempt to learn Go by building as I learn, making the journey more engaging while exploring the language in practice. I'm sharing the exercises and tooling so others can learn alongside me.
+### At a glance
+
+- Simple CLI to list and verify exercises
+- Helpful hints and solution links when you're stuck
+- Watch mode to auto-run tests on changes
+- Progress dashboard with a visual bar and checklists
+- Publish your progress to GitHub and appear on the README leaderboard
+
### Install (Go 1.22+)
```bash
@@ -35,9 +47,14 @@ golearn progress # Rich TUI with ASCII progress bar and checklist
# Auto-verify on change (watch mode)
golearn watch # Watches ./exercises and re-runs tests per edited exercise
+
+# Publish your progress (appears on README leaderboard)
+golearn publish --dry-run
+golearn publish --user
```
### Contributing
+
- See [CONTRIBUTING.md](./CONTRIBUTING.md)
- Please follow our [Code of Conduct](./CODE_OF_CONDUCT.md)
- Security issues: see [SECURITY.md](./SECURITY.md)
@@ -72,6 +89,10 @@ make watch
- Non-code lesson content and any included gopher artwork: [CC BY 3.0](./CONTENT_LICENSE).
- Inspired by rustlings and Go by Example. [NOTICE](./NOTICE) for attributions.
-
-

-
+## Leaderboard
+
+The following users have completed all exercises (ascending by completion time):
+
+
+No completions yet. Be the first!
+
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 8238351..05500f5 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -19,6 +19,7 @@ Usage:
golearn solution [name] Show solution flow (hint-first; or link)
golearn watch Watch files and re-run tests on change
golearn progress Show progress
+ golearn publish [options] Publish your progress to upstream as a PR
golearn reset [name] Reset exercise to starter state
golearn init [repo] [dir] Initialize workspace: clone exercises repo or copy built-in templates
golearn help Show this help
@@ -27,6 +28,12 @@ Global options:
--no-color Disable ANSI colors (honors NO_COLOR)
--theme= Theme: default | high-contrast | monochrome
--screen-reader, --sr Optimize for screen readers; avoid screen clears
+
+Publish options (env overrides in parentheses):
+ --repo= Upstream repo to contribute to (GOLEARN_PUBLISH_REPO)
+ --user= Your GitHub username (GOLEARN_PUBLISH_USER)
+ --branch= Branch to create for the PR
+ --dry-run Print JSON snapshot instead of creating a PR
`
}
@@ -72,6 +79,9 @@ func Execute(args []string) error {
return runWatch()
case "progress":
return runProgress()
+ case "publish":
+ // pass through remaining args to the publish handler
+ return runPublish(args[1:])
case "reset":
var name string
if len(args) > 1 {
diff --git a/internal/cli/commands.go b/internal/cli/commands.go
index 2b4d40b..5fadb54 100644
--- a/internal/cli/commands.go
+++ b/internal/cli/commands.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io/ioutil"
"os"
"os/exec"
"os/signal"
@@ -463,3 +464,366 @@ func progressBarWidth() int {
}
return 60
}
+
+// runPublish collects local progress and attempts to create a PR against the upstream repo
+// adding/refreshing a JSON snapshot under contrib/leaderboard/progress/.json.
+// It prefers the GitHub CLI if available and the user is authenticated. Otherwise, it
+// prints the snapshot and manual steps.
+func runPublish(args []string) error {
+ repoURL, userName, branchName, dryRun := parsePublishFlags(args)
+ if repoURL == "" {
+ repoURL = strings.TrimSpace(os.Getenv("GOLEARN_PUBLISH_REPO"))
+ if repoURL == "" {
+ repoURL = "https://github.com/zhravan/golearn"
+ }
+ }
+ if userName == "" {
+ userName = strings.TrimSpace(os.Getenv("GOLEARN_PUBLISH_USER"))
+ }
+ if branchName == "" {
+ branchName = fmt.Sprintf("progress/%s-%s", sanitizeForFile(userName), time.Now().Format("20060102-150405"))
+ }
+
+ // Build snapshot
+ snap, err := buildProgressSnapshot(userName)
+ if err != nil {
+ return err
+ }
+
+ if dryRun {
+ b, _ := json.MarshalIndent(snap, "", " ")
+ fmt.Println(string(b))
+ fmt.Println(theme.Muted("Dry-run: not creating a PR."))
+ return nil
+ }
+
+ // Prefer GitHub CLI if present
+ if _, lookErr := exec.LookPath("gh"); lookErr != nil {
+ fmt.Println(theme.Muted("GitHub CLI not found. Printing snapshot and manual steps."))
+ return printManualPublishInstructions(repoURL, snap)
+ }
+
+ // Ensure auth
+ if err := exec.Command("gh", "auth", "status").Run(); err != nil {
+ fmt.Println(theme.Muted("GitHub CLI not authenticated. Run 'gh auth login' first."))
+ return printManualPublishInstructions(repoURL, snap)
+ }
+
+ // If username is still empty, derive from gh
+ if strings.TrimSpace(userName) == "" {
+ out, err := exec.Command("gh", "api", "user", "-q", ".login").Output()
+ if err == nil {
+ userName = strings.TrimSpace(string(out))
+ }
+ }
+
+ owner, name := parseRepoOwnerAndName(repoURL)
+ if owner == "" || name == "" {
+ return fmt.Errorf("unable to parse repo URL: %s", repoURL)
+ }
+
+ // Ensure fork exists (no-op if already exists)
+ _ = exec.Command("gh", "repo", "fork", fmt.Sprintf("%s/%s", owner, name), "--clone=false", "--remote=false").Run()
+
+ // Work in a temporary clone of upstream
+ tmpDir, err := ioutil.TempDir("", "golearn-publish-")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(tmpDir)
+
+ cloneCmd := exec.Command("git", "clone", repoURL, ".")
+ cloneCmd.Dir = tmpDir
+ cloneCmd.Stdout = os.Stdout
+ cloneCmd.Stderr = os.Stderr
+ if err := cloneCmd.Run(); err != nil {
+ return fmt.Errorf("git clone failed: %w", err)
+ }
+
+ // Create branch
+ if err := runCmd(tmpDir, "git", "checkout", "-b", branchName); err != nil {
+ return err
+ }
+
+ // Write snapshot file
+ progressDir := filepath.Join(tmpDir, "contrib", "leaderboard", "progress")
+ if err := os.MkdirAll(progressDir, 0o755); err != nil {
+ return err
+ }
+ fileName := sanitizeForFile(userName)
+ if fileName == "" {
+ fileName = "anonymous"
+ }
+ filePath := filepath.Join(progressDir, fileName+".json")
+ b, _ := json.MarshalIndent(snap, "", " ")
+ if err := os.WriteFile(filePath, b, 0o644); err != nil {
+ return err
+ }
+
+ // Update README leaderboard section
+ if err := updateLeaderboardReadme(tmpDir, progressDir); err != nil {
+ fmt.Printf("Warning: could not update README leaderboard: %v\n", err)
+ }
+
+ if err := runCmd(tmpDir, "git", "add", filePath); err != nil {
+ return err
+ }
+ _ = runCmd(tmpDir, "git", "add", filepath.Join(tmpDir, "README.md"))
+ msg := fmt.Sprintf("chore(leaderboard): add progress for %s (%d/%d)", snap.User, snap.CompletedCount, snap.TotalCount)
+ if err := runCmd(tmpDir, "git", "commit", "-m", msg); err != nil {
+ return err
+ }
+
+ // Add fork remote and push
+ forkURL := fmt.Sprintf("https://github.com/%s/%s.git", userName, name)
+ _ = runCmd(tmpDir, "git", "remote", "remove", "fork")
+ if err := runCmd(tmpDir, "git", "remote", "add", "fork", forkURL); err != nil {
+ return err
+ }
+ if err := runCmd(tmpDir, "git", "push", "-u", "fork", branchName+":"+branchName); err != nil {
+ return err
+ }
+
+ // Create PR against upstream
+ prTitle := fmt.Sprintf("Add progress for %s (%d/%d)", snap.User, snap.CompletedCount, snap.TotalCount)
+ prBody := "Automated progress publish from golearn CLI. This adds/updates your progress snapshot for the leaderboard."
+ prCmd := exec.Command("gh", "pr", "create",
+ "--repo", fmt.Sprintf("%s/%s", owner, name),
+ "--head", fmt.Sprintf("%s:%s", userName, branchName),
+ "--base", "main",
+ "--title", prTitle,
+ "--body", prBody,
+ )
+ prCmd.Dir = tmpDir
+ prCmd.Stdout = os.Stdout
+ prCmd.Stderr = os.Stderr
+ if err := prCmd.Run(); err != nil {
+ fmt.Println(theme.Muted("Could not auto-create PR. You may need to open it manually."))
+ fmt.Printf("Branch pushed to %s:%s\n", forkURL, branchName)
+ fmt.Printf("Open a PR against %s/%s with head %s:%s\n", owner, name, userName, branchName)
+ return nil
+ }
+
+ return nil
+}
+
+func parsePublishFlags(args []string) (repo, user, branch string, dry bool) {
+ for _, a := range args {
+ if strings.HasPrefix(a, "--repo=") {
+ repo = strings.TrimSpace(strings.TrimPrefix(a, "--repo="))
+ continue
+ }
+ if strings.HasPrefix(a, "--user=") {
+ user = strings.TrimSpace(strings.TrimPrefix(a, "--user="))
+ continue
+ }
+ if strings.HasPrefix(a, "--branch=") {
+ branch = strings.TrimSpace(strings.TrimPrefix(a, "--branch="))
+ continue
+ }
+ if a == "--dry-run" || a == "--dry" {
+ dry = true
+ continue
+ }
+ }
+ return
+}
+
+type progressSnapshot struct {
+ User string `json:"user"`
+ CompletedCount int `json:"completed_count"`
+ TotalCount int `json:"total_count"`
+ Percent int `json:"percent"`
+ CompletedSlugs []string `json:"completed_slugs"`
+ Timestamp string `json:"timestamp"`
+}
+
+func buildProgressSnapshot(user string) (progressSnapshot, error) {
+ catalog, err := exercises.ListAll()
+ if err != nil {
+ return progressSnapshot{}, err
+ }
+ var all []exercises.Exercise
+ all = append(all, catalog.Concepts...)
+ all = append(all, catalog.Projects...)
+ sort.Slice(all, func(i, j int) bool { return all[i].Slug < all[j].Slug })
+
+ var completed []string
+ for _, ex := range all {
+ ok, _ := progress.IsCompleted(ex.Slug)
+ if ok {
+ completed = append(completed, ex.Slug)
+ }
+ }
+ percent := 0
+ if len(all) > 0 {
+ percent = int(float64(len(completed)) / float64(len(all)) * 100)
+ }
+ return progressSnapshot{
+ User: user,
+ CompletedCount: len(completed),
+ TotalCount: len(all),
+ Percent: percent,
+ CompletedSlugs: completed,
+ Timestamp: time.Now().UTC().Format(time.RFC3339),
+ }, nil
+}
+
+func sanitizeForFile(s string) string {
+ s = strings.TrimSpace(strings.ToLower(s))
+ if s == "" {
+ return ""
+ }
+ // replace non-alphanumeric with '-'
+ var b strings.Builder
+ for _, r := range s {
+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
+ b.WriteRune(r)
+ } else {
+ b.WriteRune('-')
+ }
+ }
+ return strings.Trim(b.String(), "-")
+}
+
+func parseRepoOwnerAndName(repoURL string) (owner, name string) {
+ url := strings.TrimSpace(repoURL)
+ url = strings.TrimSuffix(url, ".git")
+ if strings.Contains(url, "github.com") {
+ // handle https and ssh
+ if strings.HasPrefix(url, "git@") {
+ // git@github.com:owner/name(.git)
+ parts := strings.SplitN(url, ":", 2)
+ if len(parts) == 2 {
+ rest := parts[1]
+ segs := strings.Split(strings.TrimPrefix(rest, "/"), "/")
+ if len(segs) >= 2 {
+ return segs[0], segs[1]
+ }
+ }
+ } else {
+ // https://github.com/owner/name
+ idx := strings.Index(url, "github.com/")
+ if idx >= 0 {
+ rest := url[idx+len("github.com/"):]
+ segs := strings.Split(strings.TrimPrefix(rest, "/"), "/")
+ if len(segs) >= 2 {
+ return segs[0], segs[1]
+ }
+ }
+ }
+ }
+ return "", ""
+}
+
+func runCmd(dir string, name string, args ...string) error {
+ cmd := exec.Command(name, args...)
+ cmd.Dir = dir
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ return cmd.Run()
+}
+
+func printManualPublishInstructions(repoURL string, snap progressSnapshot) error {
+ b, _ := json.MarshalIndent(snap, "", " ")
+ fmt.Println(string(b))
+ fmt.Println()
+ fmt.Println(theme.Muted("Manual steps to publish your progress:"))
+ fmt.Println("1) Fork the repository if not already: https://github.com/zhravan/golearn")
+ fmt.Println("2) Clone upstream, create a branch, add the JSON under contrib/leaderboard/progress/.json, commit, push to your fork, and open a PR.")
+ fmt.Println(" Repo:", repoURL)
+ return nil
+}
+
+type leaderRow struct {
+ User string
+ Timestamp string
+}
+
+func updateLeaderboardReadme(repoDir string, progressDir string) error {
+ entries, err := os.ReadDir(progressDir)
+ if err != nil {
+ return err
+ }
+ var rows []leaderRow
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
+ continue
+ }
+ b, err := os.ReadFile(filepath.Join(progressDir, e.Name()))
+ if err != nil {
+ continue
+ }
+ var ps progressSnapshot
+ if err := json.Unmarshal(b, &ps); err != nil {
+ continue
+ }
+ if ps.TotalCount > 0 && ps.CompletedCount == ps.TotalCount {
+ rows = append(rows, leaderRow{User: ps.User, Timestamp: ps.Timestamp})
+ }
+ }
+ // sort by timestamp ascending, then username
+ sort.Slice(rows, func(i, j int) bool {
+ if rows[i].Timestamp == rows[j].Timestamp {
+ return rows[i].User < rows[j].User
+ }
+ return rows[i].Timestamp < rows[j].Timestamp
+ })
+
+ // Build dynamic content only within markers
+ var section strings.Builder
+ section.WriteString("\n")
+ if len(rows) == 0 {
+ section.WriteString("No completions yet. Be the first!\n")
+ } else {
+ section.WriteString("| Image | Username | Date |\n")
+ section.WriteString("|---|---|---|\n")
+ for _, r := range rows {
+ avatar := fmt.Sprintf("https://github.com/%s.png?size=64", r.User)
+ profile := fmt.Sprintf("https://github.com/%s", r.User)
+ section.WriteString(fmt.Sprintf("|  | [%s](%s) | %s |\n", r.User, avatar, r.User, profile, r.Timestamp))
+ }
+ }
+ section.WriteString("\n")
+
+ readmePath := filepath.Join(repoDir, "README.md")
+ old, err := os.ReadFile(readmePath)
+ if err != nil {
+ // If README missing, create a new README with header and section
+ var full strings.Builder
+ full.WriteString("## Leaderboard\n\n")
+ full.WriteString("The following users have completed all exercises (ascending by completion time):\n\n")
+ full.WriteString(section.String())
+ return os.WriteFile(readmePath, []byte(full.String()), 0o644)
+ }
+
+ // If markers are present, replace content between them; else append full section at end
+ updated := replaceBetweenMarkers(string(old), "", "", section.String())
+ if updated == string(old) {
+ // markers not found; append header + section
+ var full strings.Builder
+ full.WriteString(string(old))
+ if !strings.HasSuffix(string(old), "\n") {
+ full.WriteString("\n")
+ }
+ full.WriteString("\n## Leaderboard\n\n")
+ full.WriteString("The following users have completed all exercises (ascending by completion time):\n\n")
+ full.WriteString(section.String())
+ updated = full.String()
+ }
+ return os.WriteFile(readmePath, []byte(updated), 0o644)
+}
+
+func replaceBetweenMarkers(orig string, startMarker string, endMarker string, replacement string) string {
+ startIdx := strings.Index(orig, startMarker)
+ endIdx := strings.Index(orig, endMarker)
+ if startIdx == -1 || endIdx == -1 || endIdx < startIdx {
+ // append section at end
+ if strings.HasSuffix(orig, "\n") {
+ return orig + "\n" + replacement
+ }
+ return orig + "\n\n" + replacement
+ }
+ endIdx += len(endMarker)
+ return orig[:startIdx] + replacement + orig[endIdx:]
+}