From f3d5fd666868c6689be97b9137f950d632fa2fef Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 27 Jan 2026 21:08:51 +0000 Subject: [PATCH] feat(core): add multi-repo management CLI commands Add comprehensive CLI tooling for managing multiple repositories: Commands added: - core health: Quick health summary across all repos - core work: Status + Claude-assisted commits + push (replaces push-all.sh) - core commit: Claude-assisted commits only - core push: Push repos with unpushed commits - core pull: Pull repos that are behind - core impact : Dependency impact analysis - core issues: List GitHub issues across repos - core reviews: List PRs with review status - core ci: Check GitHub Actions status - core docs list/sync: Documentation management New packages: - pkg/repos: Registry parser for repos.yaml with dependency graph - pkg/git: Parallel git status operations Features: - Auto-discovers repos.yaml or scans current directory - Sequential GitHub API calls to avoid rate limits - Colored output with lipgloss - SSH passphrase gate preserved for push operations Co-Authored-By: Claude Opus 4.5 --- cmd/core/cmd/api.go | 4 +- cmd/core/cmd/ci.go | 261 +++++++++++++++++++++++++++++++ cmd/core/cmd/commit.go | 169 ++++++++++++++++++++ cmd/core/cmd/docs.go | 339 ++++++++++++++++++++++++++++++++++++++++ cmd/core/cmd/health.go | 223 ++++++++++++++++++++++++++ cmd/core/cmd/impact.go | 193 +++++++++++++++++++++++ cmd/core/cmd/issues.go | 259 ++++++++++++++++++++++++++++++ cmd/core/cmd/pull.go | 146 +++++++++++++++++ cmd/core/cmd/push.go | 144 +++++++++++++++++ cmd/core/cmd/reviews.go | 269 +++++++++++++++++++++++++++++++ cmd/core/cmd/root.go | 10 ++ cmd/core/cmd/work.go | 334 +++++++++++++++++++++++++++++++++++++++ cmd/core/go.mod | 30 ++-- cmd/core/go.sum | 50 +++--- go.work | 3 + go.work.sum | 106 ++++--------- pkg/git/git.go | 198 +++++++++++++++++++++++ pkg/git/go.mod | 3 + pkg/repos/go.mod | 11 ++ pkg/repos/go.sum | 5 + pkg/repos/registry.go | 310 ++++++++++++++++++++++++++++++++++++ 21 files changed, 2950 insertions(+), 117 deletions(-) create mode 100644 cmd/core/cmd/ci.go create mode 100644 cmd/core/cmd/commit.go create mode 100644 cmd/core/cmd/docs.go create mode 100644 cmd/core/cmd/health.go create mode 100644 cmd/core/cmd/impact.go create mode 100644 cmd/core/cmd/issues.go create mode 100644 cmd/core/cmd/pull.go create mode 100644 cmd/core/cmd/push.go create mode 100644 cmd/core/cmd/reviews.go create mode 100644 cmd/core/cmd/work.go create mode 100644 pkg/git/git.go create mode 100644 pkg/git/go.mod create mode 100644 pkg/repos/go.mod create mode 100644 pkg/repos/go.sum create mode 100644 pkg/repos/registry.go diff --git a/cmd/core/cmd/api.go b/cmd/core/cmd/api.go index 4302b53..d5c0bdb 100644 --- a/cmd/core/cmd/api.go +++ b/cmd/core/cmd/api.go @@ -12,6 +12,6 @@ func AddAPICommands(parent *clir.Command) { // Add the 'sync' command to 'api' AddSyncCommand(apiCmd) - // Add the 'test-gen' command to 'api' - AddTestGenCommand(apiCmd) + // TODO: Add the 'test-gen' command to 'api' + // AddTestGenCommand(apiCmd) } diff --git a/cmd/core/cmd/ci.go b/cmd/core/cmd/ci.go new file mode 100644 index 0000000..ca17c0e --- /dev/null +++ b/cmd/core/cmd/ci.go @@ -0,0 +1,261 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/Snider/Core/pkg/repos" + "github.com/charmbracelet/lipgloss" + "github.com/leaanthony/clir" +) + +var ( + ciSuccessStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#22c55e")) // green-500 + + ciFailureStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#ef4444")) // red-500 + + ciPendingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#f59e0b")) // amber-500 + + ciSkippedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6b7280")) // gray-500 +) + +// WorkflowRun represents a GitHub Actions workflow run +type WorkflowRun struct { + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + HeadBranch string `json:"headBranch"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + URL string `json:"url"` + + // Added by us + RepoName string `json:"-"` +} + +// AddCICommand adds the 'ci' command to the given parent command. +func AddCICommand(parent *clir.Cli) { + var registryPath string + var branch string + var failedOnly bool + + ciCmd := parent.NewSubCommand("ci", "Check CI status across all repos") + ciCmd.LongDescription("Fetches GitHub Actions workflow status for all repos.\n" + + "Shows latest run status for each repo.\n" + + "Requires the 'gh' CLI to be installed and authenticated.") + + ciCmd.StringFlag("registry", "Path to repos.yaml (auto-detected if not specified)", ®istryPath) + ciCmd.StringFlag("branch", "Filter by branch (default: main)", &branch) + ciCmd.BoolFlag("failed", "Show only failed runs", &failedOnly) + + ciCmd.Action(func() error { + if branch == "" { + branch = "main" + } + return runCI(registryPath, branch, failedOnly) + }) +} + +func runCI(registryPath string, branch string, failedOnly bool) error { + // Check gh is available + if _, err := exec.LookPath("gh"); err != nil { + return fmt.Errorf("'gh' CLI not found. Install from https://cli.github.com/") + } + + // Find or use provided registry + var reg *repos.Registry + var err error + + if registryPath != "" { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + registryPath, err = repos.FindRegistry() + if err == nil { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + cwd, _ := os.Getwd() + reg, err = repos.ScanDirectory(cwd) + if err != nil { + return fmt.Errorf("failed to scan directory: %w", err) + } + } + } + + // Fetch CI status sequentially + var allRuns []WorkflowRun + var fetchErrors []error + var noCI []string + + repoList := reg.List() + for i, repo := range repoList { + repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name) + fmt.Printf("\033[2K\r%s %d/%d %s", dimStyle.Render("Checking"), i+1, len(repoList), repo.Name) + + runs, err := fetchWorkflowRuns(repoFullName, repo.Name, branch) + if err != nil { + if strings.Contains(err.Error(), "no workflows") { + noCI = append(noCI, repo.Name) + } else { + fetchErrors = append(fetchErrors, fmt.Errorf("%s: %w", repo.Name, err)) + } + continue + } + + if len(runs) > 0 { + // Just get the latest run + allRuns = append(allRuns, runs[0]) + } else { + noCI = append(noCI, repo.Name) + } + } + fmt.Print("\033[2K\r") // Clear progress line + + // Count by status + var success, failed, pending, other int + for _, run := range allRuns { + switch run.Conclusion { + case "success": + success++ + case "failure": + failed++ + case "": + if run.Status == "in_progress" || run.Status == "queued" { + pending++ + } else { + other++ + } + default: + other++ + } + } + + // Print summary + fmt.Println() + fmt.Printf("%d repos checked", len(repoList)) + if success > 0 { + fmt.Printf(" · %s", ciSuccessStyle.Render(fmt.Sprintf("%d passing", success))) + } + if failed > 0 { + fmt.Printf(" · %s", ciFailureStyle.Render(fmt.Sprintf("%d failing", failed))) + } + if pending > 0 { + fmt.Printf(" · %s", ciPendingStyle.Render(fmt.Sprintf("%d pending", pending))) + } + if len(noCI) > 0 { + fmt.Printf(" · %s", ciSkippedStyle.Render(fmt.Sprintf("%d no CI", len(noCI)))) + } + fmt.Println() + fmt.Println() + + // Filter if needed + displayRuns := allRuns + if failedOnly { + displayRuns = nil + for _, run := range allRuns { + if run.Conclusion == "failure" { + displayRuns = append(displayRuns, run) + } + } + } + + // Print details + for _, run := range displayRuns { + printWorkflowRun(run) + } + + // Print errors + if len(fetchErrors) > 0 { + fmt.Println() + for _, err := range fetchErrors { + fmt.Printf("%s %s\n", errorStyle.Render("Error:"), err) + } + } + + return nil +} + +func fetchWorkflowRuns(repoFullName, repoName string, branch string) ([]WorkflowRun, error) { + args := []string{ + "run", "list", + "--repo", repoFullName, + "--branch", branch, + "--limit", "1", + "--json", "name,status,conclusion,headBranch,createdAt,updatedAt,url", + } + + cmd := exec.Command("gh", args...) + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := string(exitErr.Stderr) + return nil, fmt.Errorf("%s", strings.TrimSpace(stderr)) + } + return nil, err + } + + var runs []WorkflowRun + if err := json.Unmarshal(output, &runs); err != nil { + return nil, err + } + + // Tag with repo name + for i := range runs { + runs[i].RepoName = repoName + } + + return runs, nil +} + +func printWorkflowRun(run WorkflowRun) { + // Status icon + var status string + switch run.Conclusion { + case "success": + status = ciSuccessStyle.Render("✓") + case "failure": + status = ciFailureStyle.Render("✗") + case "": + if run.Status == "in_progress" { + status = ciPendingStyle.Render("●") + } else if run.Status == "queued" { + status = ciPendingStyle.Render("○") + } else { + status = ciSkippedStyle.Render("—") + } + case "skipped": + status = ciSkippedStyle.Render("—") + case "cancelled": + status = ciSkippedStyle.Render("○") + default: + status = ciSkippedStyle.Render("?") + } + + // Workflow name (truncated) + workflowName := truncate(run.Name, 20) + + // Age + age := formatAge(run.UpdatedAt) + + fmt.Printf(" %s %-18s %-22s %s\n", + status, + repoNameStyle.Render(run.RepoName), + dimStyle.Render(workflowName), + issueAgeStyle.Render(age), + ) +} diff --git a/cmd/core/cmd/commit.go b/cmd/core/cmd/commit.go new file mode 100644 index 0000000..f277918 --- /dev/null +++ b/cmd/core/cmd/commit.go @@ -0,0 +1,169 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/Snider/Core/pkg/git" + "github.com/Snider/Core/pkg/repos" + "github.com/leaanthony/clir" +) + +// AddCommitCommand adds the 'commit' command to the given parent command. +func AddCommitCommand(parent *clir.Cli) { + var registryPath string + var all bool + + commitCmd := parent.NewSubCommand("commit", "Claude-assisted commits across repos") + commitCmd.LongDescription("Uses Claude to create commits for dirty repos.\n" + + "Shows uncommitted changes and invokes Claude to generate commit messages.") + + commitCmd.StringFlag("registry", "Path to repos.yaml (auto-detected if not specified)", ®istryPath) + commitCmd.BoolFlag("all", "Commit all dirty repos without prompting", &all) + + commitCmd.Action(func() error { + return runCommit(registryPath, all) + }) +} + +func runCommit(registryPath string, all bool) error { + ctx := context.Background() + + // Find or use provided registry, fall back to directory scan + var reg *repos.Registry + var err error + + if registryPath != "" { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + registryPath, err = repos.FindRegistry() + if err == nil { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + // Fallback: scan current directory + cwd, _ := os.Getwd() + reg, err = repos.ScanDirectory(cwd) + if err != nil { + return fmt.Errorf("failed to scan directory: %w", err) + } + registryPath = cwd + } + } + + // Build paths and names for git operations + var paths []string + names := make(map[string]string) + + for _, repo := range reg.List() { + if repo.IsGitRepo() { + paths = append(paths, repo.Path) + names[repo.Path] = repo.Name + } + } + + if len(paths) == 0 { + fmt.Println("No git repositories found.") + return nil + } + + // Get status for all repos + statuses := git.Status(ctx, git.StatusOptions{ + Paths: paths, + Names: names, + }) + + // Find dirty repos + var dirtyRepos []git.RepoStatus + for _, s := range statuses { + if s.Error == nil && s.IsDirty() { + dirtyRepos = append(dirtyRepos, s) + } + } + + if len(dirtyRepos) == 0 { + fmt.Println("No uncommitted changes found.") + return nil + } + + // Show dirty repos + fmt.Printf("\n%d repo(s) with uncommitted changes:\n\n", len(dirtyRepos)) + for _, s := range dirtyRepos { + fmt.Printf(" %s: ", repoNameStyle.Render(s.Name)) + if s.Modified > 0 { + fmt.Printf("%s ", dirtyStyle.Render(fmt.Sprintf("%d modified", s.Modified))) + } + if s.Untracked > 0 { + fmt.Printf("%s ", dirtyStyle.Render(fmt.Sprintf("%d untracked", s.Untracked))) + } + if s.Staged > 0 { + fmt.Printf("%s ", aheadStyle.Render(fmt.Sprintf("%d staged", s.Staged))) + } + fmt.Println() + } + + // Confirm unless --all + if !all { + fmt.Println() + if !confirm("Have Claude commit these repos?") { + fmt.Println("Aborted.") + return nil + } + } + + fmt.Println() + + // Commit each dirty repo + var succeeded, failed int + for _, s := range dirtyRepos { + fmt.Printf("%s %s\n", dimStyle.Render("Committing"), s.Name) + + if err := claudeCommit(ctx, s.Path, s.Name, registryPath); err != nil { + fmt.Printf(" %s %s\n", errorStyle.Render("✗"), err) + failed++ + } else { + fmt.Printf(" %s committed\n", successStyle.Render("✓")) + succeeded++ + } + fmt.Println() + } + + // Summary + fmt.Printf("%s %d succeeded", successStyle.Render("Done:"), succeeded) + if failed > 0 { + fmt.Printf(", %s", errorStyle.Render(fmt.Sprintf("%d failed", failed))) + } + fmt.Println() + + return nil +} + +// claudeCommit is defined in work.go but we need it here too +// This version includes better output handling +func claudeCommitWithOutput(ctx context.Context, repoPath, repoName, registryPath string) error { + // Load AGENTS.md context if available + agentsPath := filepath.Join(filepath.Dir(registryPath), "AGENTS.md") + var agentContext string + if data, err := os.ReadFile(agentsPath); err == nil { + agentContext = string(data) + "\n\n" + } + + prompt := agentContext + "Review the uncommitted changes and create an appropriate commit. " + + "Use Co-Authored-By: Claude Opus 4.5 . Be concise." + + cmd := exec.CommandContext(ctx, "claude", "-p", prompt, "--allowedTools", "Bash,Read,Glob,Grep") + cmd.Dir = repoPath + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + return cmd.Run() +} diff --git a/cmd/core/cmd/docs.go b/cmd/core/cmd/docs.go new file mode 100644 index 0000000..b4e1467 --- /dev/null +++ b/cmd/core/cmd/docs.go @@ -0,0 +1,339 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Snider/Core/pkg/repos" + "github.com/charmbracelet/lipgloss" + "github.com/leaanthony/clir" +) + +var ( + docsFoundStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#22c55e")) // green-500 + + docsMissingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6b7280")) // gray-500 + + docsFileStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#3b82f6")) // blue-500 +) + +// RepoDocInfo holds documentation info for a repo +type RepoDocInfo struct { + Name string + Path string + HasDocs bool + Readme string + ClaudeMd string + Changelog string + DocsDir []string // Files in docs/ directory +} + +// AddDocsCommand adds the 'docs' command to the given parent command. +func AddDocsCommand(parent *clir.Cli) { + docsCmd := parent.NewSubCommand("docs", "Documentation management") + docsCmd.LongDescription("Manage documentation across all repos.\n" + + "Scan for docs, check coverage, and sync to core.help.") + + // Add subcommands + addDocsSyncCommand(docsCmd) + addDocsListCommand(docsCmd) +} + +func addDocsSyncCommand(parent *clir.Command) { + var registryPath string + var dryRun bool + var outputDir string + + syncCmd := parent.NewSubCommand("sync", "Sync documentation to output directory") + syncCmd.StringFlag("registry", "Path to repos.yaml", ®istryPath) + syncCmd.BoolFlag("dry-run", "Show what would be synced without copying", &dryRun) + syncCmd.StringFlag("output", "Output directory (default: ./docs-build)", &outputDir) + + syncCmd.Action(func() error { + if outputDir == "" { + outputDir = "./docs-build" + } + return runDocsSync(registryPath, outputDir, dryRun) + }) +} + +func addDocsListCommand(parent *clir.Command) { + var registryPath string + + listCmd := parent.NewSubCommand("list", "List documentation across repos") + listCmd.StringFlag("registry", "Path to repos.yaml", ®istryPath) + + listCmd.Action(func() error { + return runDocsList(registryPath) + }) +} + +func runDocsSync(registryPath string, outputDir string, dryRun bool) error { + // Find or use provided registry + var reg *repos.Registry + var err error + + if registryPath != "" { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + registryPath, err = repos.FindRegistry() + if err == nil { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + cwd, _ := os.Getwd() + reg, err = repos.ScanDirectory(cwd) + if err != nil { + return fmt.Errorf("failed to scan directory: %w", err) + } + } + } + + // Scan all repos for docs + var docsInfo []RepoDocInfo + for _, repo := range reg.List() { + info := scanRepoDocs(repo) + if info.HasDocs { + docsInfo = append(docsInfo, info) + } + } + + if len(docsInfo) == 0 { + fmt.Println("No documentation found in any repos.") + return nil + } + + fmt.Printf("\n%s %d repo(s) with documentation\n\n", dimStyle.Render("Found"), len(docsInfo)) + + // Show what will be synced + var totalFiles int + for _, info := range docsInfo { + files := countDocFiles(info) + totalFiles += files + fmt.Printf(" %s %s\n", repoNameStyle.Render(info.Name), dimStyle.Render(fmt.Sprintf("(%d files)", files))) + + if info.Readme != "" { + fmt.Printf(" %s\n", docsFileStyle.Render("README.md")) + } + if info.ClaudeMd != "" { + fmt.Printf(" %s\n", docsFileStyle.Render("CLAUDE.md")) + } + if info.Changelog != "" { + fmt.Printf(" %s\n", docsFileStyle.Render("CHANGELOG.md")) + } + for _, f := range info.DocsDir { + fmt.Printf(" %s\n", docsFileStyle.Render("docs/"+f)) + } + } + + fmt.Printf("\n%s %d files from %d repos\n", dimStyle.Render("Total:"), totalFiles, len(docsInfo)) + + if dryRun { + fmt.Printf("\n%s\n", dimStyle.Render("Dry run - no files copied")) + return nil + } + + // Confirm + fmt.Println() + if !confirm(fmt.Sprintf("Sync to %s?", outputDir)) { + fmt.Println("Aborted.") + return nil + } + + // Create output directory + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + // Sync docs + fmt.Println() + var synced int + for _, info := range docsInfo { + repoOutDir := filepath.Join(outputDir, "packages", info.Name) + if err := os.MkdirAll(repoOutDir, 0755); err != nil { + fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), info.Name, err) + continue + } + + // Copy files + if info.Readme != "" { + copyFile(info.Readme, filepath.Join(repoOutDir, "index.md")) + } + if info.ClaudeMd != "" { + copyFile(info.ClaudeMd, filepath.Join(repoOutDir, "claude.md")) + } + if info.Changelog != "" { + copyFile(info.Changelog, filepath.Join(repoOutDir, "changelog.md")) + } + for _, f := range info.DocsDir { + src := filepath.Join(info.Path, "docs", f) + dst := filepath.Join(repoOutDir, f) + os.MkdirAll(filepath.Dir(dst), 0755) + copyFile(src, dst) + } + + fmt.Printf(" %s %s\n", successStyle.Render("✓"), info.Name) + synced++ + } + + fmt.Printf("\n%s Synced %d repos to %s\n", successStyle.Render("Done:"), synced, outputDir) + + return nil +} + +func runDocsList(registryPath string) error { + // Find or use provided registry + var reg *repos.Registry + var err error + + if registryPath != "" { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + registryPath, err = repos.FindRegistry() + if err == nil { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + cwd, _ := os.Getwd() + reg, err = repos.ScanDirectory(cwd) + if err != nil { + return fmt.Errorf("failed to scan directory: %w", err) + } + } + } + + fmt.Printf("\n%-20s %-8s %-8s %-10s %s\n", + headerStyle.Render("Repo"), + headerStyle.Render("README"), + headerStyle.Render("CLAUDE"), + headerStyle.Render("CHANGELOG"), + headerStyle.Render("docs/"), + ) + fmt.Println(strings.Repeat("─", 70)) + + var withDocs, withoutDocs int + for _, repo := range reg.List() { + info := scanRepoDocs(repo) + + readme := docsMissingStyle.Render("—") + if info.Readme != "" { + readme = docsFoundStyle.Render("✓") + } + + claude := docsMissingStyle.Render("—") + if info.ClaudeMd != "" { + claude = docsFoundStyle.Render("✓") + } + + changelog := docsMissingStyle.Render("—") + if info.Changelog != "" { + changelog = docsFoundStyle.Render("✓") + } + + docsDir := docsMissingStyle.Render("—") + if len(info.DocsDir) > 0 { + docsDir = docsFoundStyle.Render(fmt.Sprintf("%d files", len(info.DocsDir))) + } + + fmt.Printf("%-20s %-8s %-8s %-10s %s\n", + repoNameStyle.Render(info.Name), + readme, + claude, + changelog, + docsDir, + ) + + if info.HasDocs { + withDocs++ + } else { + withoutDocs++ + } + } + + fmt.Println() + fmt.Printf("%s %d with docs, %d without\n", + dimStyle.Render("Coverage:"), + withDocs, + withoutDocs, + ) + + return nil +} + +func scanRepoDocs(repo *repos.Repo) RepoDocInfo { + info := RepoDocInfo{ + Name: repo.Name, + Path: repo.Path, + } + + // Check for README.md + readme := filepath.Join(repo.Path, "README.md") + if _, err := os.Stat(readme); err == nil { + info.Readme = readme + info.HasDocs = true + } + + // Check for CLAUDE.md + claudeMd := filepath.Join(repo.Path, "CLAUDE.md") + if _, err := os.Stat(claudeMd); err == nil { + info.ClaudeMd = claudeMd + info.HasDocs = true + } + + // Check for CHANGELOG.md + changelog := filepath.Join(repo.Path, "CHANGELOG.md") + if _, err := os.Stat(changelog); err == nil { + info.Changelog = changelog + info.HasDocs = true + } + + // Check for docs/ directory + docsDir := filepath.Join(repo.Path, "docs") + if entries, err := os.ReadDir(docsDir); err == nil { + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") { + info.DocsDir = append(info.DocsDir, e.Name()) + info.HasDocs = true + } + } + } + + return info +} + +func countDocFiles(info RepoDocInfo) int { + count := len(info.DocsDir) + if info.Readme != "" { + count++ + } + if info.ClaudeMd != "" { + count++ + } + if info.Changelog != "" { + count++ + } + return count +} + +func copyFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, 0644) +} diff --git a/cmd/core/cmd/health.go b/cmd/core/cmd/health.go new file mode 100644 index 0000000..f882f13 --- /dev/null +++ b/cmd/core/cmd/health.go @@ -0,0 +1,223 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "sort" + + "github.com/Snider/Core/pkg/git" + "github.com/Snider/Core/pkg/repos" + "github.com/charmbracelet/lipgloss" + "github.com/leaanthony/clir" +) + +var ( + healthLabelStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6b7280")) // gray-500 + + healthValueStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#e2e8f0")) // gray-200 + + healthGoodStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#22c55e")) // green-500 + + healthWarnStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#f59e0b")) // amber-500 + + healthBadStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#ef4444")) // red-500 +) + +// AddHealthCommand adds the 'health' command to the given parent command. +func AddHealthCommand(parent *clir.Cli) { + var registryPath string + var verbose bool + + healthCmd := parent.NewSubCommand("health", "Quick health check across all repos") + healthCmd.LongDescription("Shows a summary of repository health:\n" + + "total repos, dirty repos, unpushed commits, etc.") + + healthCmd.StringFlag("registry", "Path to repos.yaml (auto-detected if not specified)", ®istryPath) + healthCmd.BoolFlag("verbose", "Show detailed breakdown", &verbose) + + healthCmd.Action(func() error { + return runHealth(registryPath, verbose) + }) +} + +func runHealth(registryPath string, verbose bool) error { + ctx := context.Background() + + // Find or use provided registry, fall back to directory scan + var reg *repos.Registry + var err error + + if registryPath != "" { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + registryPath, err = repos.FindRegistry() + if err == nil { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + // Fallback: scan current directory + cwd, _ := os.Getwd() + reg, err = repos.ScanDirectory(cwd) + if err != nil { + return fmt.Errorf("failed to scan directory: %w", err) + } + } + } + + // Build paths and names for git operations + var paths []string + names := make(map[string]string) + + for _, repo := range reg.List() { + if repo.IsGitRepo() { + paths = append(paths, repo.Path) + names[repo.Path] = repo.Name + } + } + + if len(paths) == 0 { + fmt.Println("No git repositories found.") + return nil + } + + // Get status for all repos + statuses := git.Status(ctx, git.StatusOptions{ + Paths: paths, + Names: names, + }) + + // Sort for consistent verbose output + sort.Slice(statuses, func(i, j int) bool { + return statuses[i].Name < statuses[j].Name + }) + + // Aggregate stats + var ( + totalRepos = len(statuses) + dirtyRepos []string + aheadRepos []string + behindRepos []string + errorRepos []string + ) + + for _, s := range statuses { + if s.Error != nil { + errorRepos = append(errorRepos, s.Name) + continue + } + if s.IsDirty() { + dirtyRepos = append(dirtyRepos, s.Name) + } + if s.HasUnpushed() { + aheadRepos = append(aheadRepos, s.Name) + } + if s.HasUnpulled() { + behindRepos = append(behindRepos, s.Name) + } + } + + // Print summary line + fmt.Println() + printHealthSummary(totalRepos, dirtyRepos, aheadRepos, behindRepos, errorRepos) + fmt.Println() + + // Verbose output + if verbose { + if len(dirtyRepos) > 0 { + fmt.Printf("%s %s\n", healthWarnStyle.Render("Dirty:"), formatRepoList(dirtyRepos)) + } + if len(aheadRepos) > 0 { + fmt.Printf("%s %s\n", healthGoodStyle.Render("Ahead:"), formatRepoList(aheadRepos)) + } + if len(behindRepos) > 0 { + fmt.Printf("%s %s\n", healthWarnStyle.Render("Behind:"), formatRepoList(behindRepos)) + } + if len(errorRepos) > 0 { + fmt.Printf("%s %s\n", healthBadStyle.Render("Errors:"), formatRepoList(errorRepos)) + } + fmt.Println() + } + + return nil +} + +func printHealthSummary(total int, dirty, ahead, behind, errors []string) { + // Total repos + fmt.Print(healthValueStyle.Render(fmt.Sprintf("%d", total))) + fmt.Print(healthLabelStyle.Render(" repos")) + + // Separator + fmt.Print(healthLabelStyle.Render(" │ ")) + + // Dirty + if len(dirty) > 0 { + fmt.Print(healthWarnStyle.Render(fmt.Sprintf("%d", len(dirty)))) + fmt.Print(healthLabelStyle.Render(" dirty")) + } else { + fmt.Print(healthGoodStyle.Render("clean")) + } + + // Separator + fmt.Print(healthLabelStyle.Render(" │ ")) + + // Ahead + if len(ahead) > 0 { + fmt.Print(healthValueStyle.Render(fmt.Sprintf("%d", len(ahead)))) + fmt.Print(healthLabelStyle.Render(" to push")) + } else { + fmt.Print(healthGoodStyle.Render("synced")) + } + + // Separator + fmt.Print(healthLabelStyle.Render(" │ ")) + + // Behind + if len(behind) > 0 { + fmt.Print(healthWarnStyle.Render(fmt.Sprintf("%d", len(behind)))) + fmt.Print(healthLabelStyle.Render(" to pull")) + } else { + fmt.Print(healthGoodStyle.Render("up to date")) + } + + // Errors (only if any) + if len(errors) > 0 { + fmt.Print(healthLabelStyle.Render(" │ ")) + fmt.Print(healthBadStyle.Render(fmt.Sprintf("%d", len(errors)))) + fmt.Print(healthLabelStyle.Render(" errors")) + } + + fmt.Println() +} + +func formatRepoList(repos []string) string { + if len(repos) <= 5 { + return joinRepos(repos) + } + return joinRepos(repos[:5]) + fmt.Sprintf(" +%d more", len(repos)-5) +} + +func joinRepos(repos []string) string { + result := "" + for i, r := range repos { + if i > 0 { + result += ", " + } + result += r + } + return result +} diff --git a/cmd/core/cmd/impact.go b/cmd/core/cmd/impact.go new file mode 100644 index 0000000..06e1fac --- /dev/null +++ b/cmd/core/cmd/impact.go @@ -0,0 +1,193 @@ +package cmd + +import ( + "fmt" + "os" + "sort" + + "github.com/Snider/Core/pkg/repos" + "github.com/charmbracelet/lipgloss" + "github.com/leaanthony/clir" +) + +var ( + impactDirectStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#ef4444")) // red-500 + + impactIndirectStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#f59e0b")) // amber-500 + + impactSafeStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#22c55e")) // green-500 +) + +// AddImpactCommand adds the 'impact' command to the given parent command. +func AddImpactCommand(parent *clir.Cli) { + var registryPath string + + impactCmd := parent.NewSubCommand("impact", "Show impact of changing a repo") + impactCmd.LongDescription("Analyzes the dependency graph to show which repos\n" + + "would be affected by changes to the specified repo.") + + impactCmd.StringFlag("registry", "Path to repos.yaml (auto-detected if not specified)", ®istryPath) + + impactCmd.Action(func() error { + args := os.Args[2:] // Skip "core" and "impact" + // Filter out flags + var repoName string + for _, arg := range args { + if arg[0] != '-' { + repoName = arg + break + } + } + if repoName == "" { + return fmt.Errorf("usage: core impact ") + } + return runImpact(registryPath, repoName) + }) +} + +func runImpact(registryPath string, repoName string) error { + // Find or use provided registry + var reg *repos.Registry + var err error + + if registryPath != "" { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + registryPath, err = repos.FindRegistry() + if err == nil { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + return fmt.Errorf("impact analysis requires repos.yaml with dependency information") + } + } + + // Check repo exists + repo, exists := reg.Get(repoName) + if !exists { + return fmt.Errorf("repo '%s' not found in registry", repoName) + } + + // Build reverse dependency graph + dependents := buildDependentsGraph(reg) + + // Find all affected repos (direct and transitive) + direct := dependents[repoName] + allAffected := findAllDependents(repoName, dependents) + + // Separate direct vs indirect + directSet := make(map[string]bool) + for _, d := range direct { + directSet[d] = true + } + + var indirect []string + for _, a := range allAffected { + if !directSet[a] { + indirect = append(indirect, a) + } + } + + // Sort for consistent output + sort.Strings(direct) + sort.Strings(indirect) + + // Print results + fmt.Println() + fmt.Printf("%s %s\n", dimStyle.Render("Impact analysis for"), repoNameStyle.Render(repoName)) + if repo.Description != "" { + fmt.Printf("%s\n", dimStyle.Render(repo.Description)) + } + fmt.Println() + + if len(allAffected) == 0 { + fmt.Printf("%s No repos depend on %s\n", impactSafeStyle.Render("✓"), repoName) + return nil + } + + // Direct dependents + if len(direct) > 0 { + fmt.Printf("%s %d direct dependent(s):\n", + impactDirectStyle.Render("●"), + len(direct), + ) + for _, d := range direct { + r, _ := reg.Get(d) + desc := "" + if r != nil && r.Description != "" { + desc = dimStyle.Render(" - " + truncate(r.Description, 40)) + } + fmt.Printf(" %s%s\n", d, desc) + } + fmt.Println() + } + + // Indirect dependents + if len(indirect) > 0 { + fmt.Printf("%s %d transitive dependent(s):\n", + impactIndirectStyle.Render("○"), + len(indirect), + ) + for _, d := range indirect { + r, _ := reg.Get(d) + desc := "" + if r != nil && r.Description != "" { + desc = dimStyle.Render(" - " + truncate(r.Description, 40)) + } + fmt.Printf(" %s%s\n", d, desc) + } + fmt.Println() + } + + // Summary + fmt.Printf("%s Changes to %s affect %s\n", + dimStyle.Render("Summary:"), + repoNameStyle.Render(repoName), + impactDirectStyle.Render(fmt.Sprintf("%d/%d repos", len(allAffected), len(reg.Repos)-1)), + ) + + return nil +} + +// buildDependentsGraph creates a reverse dependency map +// key = repo, value = repos that depend on it +func buildDependentsGraph(reg *repos.Registry) map[string][]string { + dependents := make(map[string][]string) + + for name, repo := range reg.Repos { + for _, dep := range repo.DependsOn { + dependents[dep] = append(dependents[dep], name) + } + } + + return dependents +} + +// findAllDependents recursively finds all repos that depend on the given repo +func findAllDependents(repoName string, dependents map[string][]string) []string { + visited := make(map[string]bool) + var result []string + + var visit func(name string) + visit = func(name string) { + for _, dep := range dependents[name] { + if !visited[dep] { + visited[dep] = true + result = append(result, dep) + visit(dep) // Recurse for transitive deps + } + } + } + + visit(repoName) + return result +} diff --git a/cmd/core/cmd/issues.go b/cmd/core/cmd/issues.go new file mode 100644 index 0000000..a721e80 --- /dev/null +++ b/cmd/core/cmd/issues.go @@ -0,0 +1,259 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "sort" + "strings" + "time" + + "github.com/Snider/Core/pkg/repos" + "github.com/charmbracelet/lipgloss" + "github.com/leaanthony/clir" +) + +var ( + issueRepoStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6b7280")) // gray-500 + + issueNumberStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#3b82f6")) // blue-500 + + issueTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#e2e8f0")) // gray-200 + + issueLabelStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#f59e0b")) // amber-500 + + issueAssigneeStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#22c55e")) // green-500 + + issueAgeStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6b7280")) // gray-500 +) + +// GitHubIssue represents a GitHub issue from the API. +type GitHubIssue struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + CreatedAt time.Time `json:"createdAt"` + Author struct { + Login string `json:"login"` + } `json:"author"` + Assignees struct { + Nodes []struct { + Login string `json:"login"` + } `json:"nodes"` + } `json:"assignees"` + Labels struct { + Nodes []struct { + Name string `json:"name"` + } `json:"nodes"` + } `json:"labels"` + URL string `json:"url"` + + // Added by us + RepoName string `json:"-"` +} + +// AddIssuesCommand adds the 'issues' command to the given parent command. +func AddIssuesCommand(parent *clir.Cli) { + var registryPath string + var limit int + var assignee string + + issuesCmd := parent.NewSubCommand("issues", "List open issues across all repos") + issuesCmd.LongDescription("Fetches open issues from GitHub for all repos in the registry.\n" + + "Requires the 'gh' CLI to be installed and authenticated.") + + issuesCmd.StringFlag("registry", "Path to repos.yaml (auto-detected if not specified)", ®istryPath) + issuesCmd.IntFlag("limit", "Max issues per repo (default 10)", &limit) + issuesCmd.StringFlag("assignee", "Filter by assignee (use @me for yourself)", &assignee) + + issuesCmd.Action(func() error { + if limit == 0 { + limit = 10 + } + return runIssues(registryPath, limit, assignee) + }) +} + +func runIssues(registryPath string, limit int, assignee string) error { + // Check gh is available + if _, err := exec.LookPath("gh"); err != nil { + return fmt.Errorf("'gh' CLI not found. Install from https://cli.github.com/") + } + + // Find or use provided registry, fall back to directory scan + var reg *repos.Registry + var err error + + if registryPath != "" { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + registryPath, err = repos.FindRegistry() + if err == nil { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + // Fallback: scan current directory + cwd, _ := os.Getwd() + reg, err = repos.ScanDirectory(cwd) + if err != nil { + return fmt.Errorf("failed to scan directory: %w", err) + } + } + } + + // Fetch issues sequentially (avoid GitHub rate limits) + var allIssues []GitHubIssue + var fetchErrors []error + + repoList := reg.List() + for i, repo := range repoList { + repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name) + fmt.Printf("\033[2K\r%s %d/%d %s", dimStyle.Render("Fetching"), i+1, len(repoList), repo.Name) + + issues, err := fetchIssues(repoFullName, repo.Name, limit, assignee) + if err != nil { + fetchErrors = append(fetchErrors, fmt.Errorf("%s: %w", repo.Name, err)) + continue + } + allIssues = append(allIssues, issues...) + } + fmt.Print("\033[2K\r") // Clear progress line + + // Sort by created date (newest first) + sort.Slice(allIssues, func(i, j int) bool { + return allIssues[i].CreatedAt.After(allIssues[j].CreatedAt) + }) + + // Print issues + if len(allIssues) == 0 { + fmt.Println("No open issues found.") + return nil + } + + fmt.Printf("\n%d open issue(s):\n\n", len(allIssues)) + + for _, issue := range allIssues { + printIssue(issue) + } + + // Print any errors + if len(fetchErrors) > 0 { + fmt.Println() + for _, err := range fetchErrors { + fmt.Printf("%s %s\n", errorStyle.Render("Error:"), err) + } + } + + return nil +} + +func fetchIssues(repoFullName, repoName string, limit int, assignee string) ([]GitHubIssue, error) { + args := []string{ + "issue", "list", + "--repo", repoFullName, + "--state", "open", + "--limit", fmt.Sprintf("%d", limit), + "--json", "number,title,state,createdAt,author,assignees,labels,url", + } + + if assignee != "" { + args = append(args, "--assignee", assignee) + } + + cmd := exec.Command("gh", args...) + output, err := cmd.Output() + if err != nil { + // Check if it's just "no issues" vs actual error + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := string(exitErr.Stderr) + if strings.Contains(stderr, "no issues") || strings.Contains(stderr, "Could not resolve") { + return nil, nil + } + return nil, fmt.Errorf("%s", stderr) + } + return nil, err + } + + var issues []GitHubIssue + if err := json.Unmarshal(output, &issues); err != nil { + return nil, err + } + + // Tag with repo name + for i := range issues { + issues[i].RepoName = repoName + } + + return issues, nil +} + +func printIssue(issue GitHubIssue) { + // #42 [core-bio] Fix avatar upload + num := issueNumberStyle.Render(fmt.Sprintf("#%d", issue.Number)) + repo := issueRepoStyle.Render(fmt.Sprintf("[%s]", issue.RepoName)) + title := issueTitleStyle.Render(truncate(issue.Title, 60)) + + line := fmt.Sprintf(" %s %s %s", num, repo, title) + + // Add labels if any + if len(issue.Labels.Nodes) > 0 { + var labels []string + for _, l := range issue.Labels.Nodes { + labels = append(labels, l.Name) + } + line += " " + issueLabelStyle.Render("["+strings.Join(labels, ", ")+"]") + } + + // Add assignee if any + if len(issue.Assignees.Nodes) > 0 { + var assignees []string + for _, a := range issue.Assignees.Nodes { + assignees = append(assignees, "@"+a.Login) + } + line += " " + issueAssigneeStyle.Render(strings.Join(assignees, ", ")) + } + + // Add age + age := formatAge(issue.CreatedAt) + line += " " + issueAgeStyle.Render(age) + + fmt.Println(line) +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-3] + "..." +} + +func formatAge(t time.Time) string { + d := time.Since(t) + + if d < time.Hour { + return fmt.Sprintf("%dm ago", int(d.Minutes())) + } + if d < 24*time.Hour { + return fmt.Sprintf("%dh ago", int(d.Hours())) + } + if d < 7*24*time.Hour { + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } + if d < 30*24*time.Hour { + return fmt.Sprintf("%dw ago", int(d.Hours()/(24*7))) + } + return fmt.Sprintf("%dmo ago", int(d.Hours()/(24*30))) +} diff --git a/cmd/core/cmd/pull.go b/cmd/core/cmd/pull.go new file mode 100644 index 0000000..fe5a034 --- /dev/null +++ b/cmd/core/cmd/pull.go @@ -0,0 +1,146 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/exec" + + "github.com/Snider/Core/pkg/git" + "github.com/Snider/Core/pkg/repos" + "github.com/leaanthony/clir" +) + +// AddPullCommand adds the 'pull' command to the given parent command. +func AddPullCommand(parent *clir.Cli) { + var registryPath string + var all bool + + pullCmd := parent.NewSubCommand("pull", "Pull updates across all repos") + pullCmd.LongDescription("Pulls updates for all repos.\n" + + "By default only pulls repos that are behind. Use --all to pull all repos.") + + pullCmd.StringFlag("registry", "Path to repos.yaml (auto-detected if not specified)", ®istryPath) + pullCmd.BoolFlag("all", "Pull all repos, not just those behind", &all) + + pullCmd.Action(func() error { + return runPull(registryPath, all) + }) +} + +func runPull(registryPath string, all bool) error { + ctx := context.Background() + + // Find or use provided registry, fall back to directory scan + var reg *repos.Registry + var err error + + if registryPath != "" { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + registryPath, err = repos.FindRegistry() + if err == nil { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + // Fallback: scan current directory + cwd, _ := os.Getwd() + reg, err = repos.ScanDirectory(cwd) + if err != nil { + return fmt.Errorf("failed to scan directory: %w", err) + } + } + } + + // Build paths and names for git operations + var paths []string + names := make(map[string]string) + + for _, repo := range reg.List() { + if repo.IsGitRepo() { + paths = append(paths, repo.Path) + names[repo.Path] = repo.Name + } + } + + if len(paths) == 0 { + fmt.Println("No git repositories found.") + return nil + } + + // Get status for all repos + statuses := git.Status(ctx, git.StatusOptions{ + Paths: paths, + Names: names, + }) + + // Find repos to pull + var toPull []git.RepoStatus + for _, s := range statuses { + if s.Error != nil { + continue + } + if all || s.HasUnpulled() { + toPull = append(toPull, s) + } + } + + if len(toPull) == 0 { + fmt.Println("All repos up to date. Nothing to pull.") + return nil + } + + // Show what we're pulling + if all { + fmt.Printf("\nPulling %d repo(s):\n\n", len(toPull)) + } else { + fmt.Printf("\n%d repo(s) behind upstream:\n\n", len(toPull)) + for _, s := range toPull { + fmt.Printf(" %s: %s\n", + repoNameStyle.Render(s.Name), + dimStyle.Render(fmt.Sprintf("%d commit(s) behind", s.Behind)), + ) + } + fmt.Println() + } + + // Pull each repo + var succeeded, failed int + for _, s := range toPull { + fmt.Printf(" %s %s... ", dimStyle.Render("Pulling"), s.Name) + + err := gitPull(ctx, s.Path) + if err != nil { + fmt.Printf("%s\n", errorStyle.Render("✗ "+err.Error())) + failed++ + } else { + fmt.Printf("%s\n", successStyle.Render("✓")) + succeeded++ + } + } + + // Summary + fmt.Println() + fmt.Printf("%s %d pulled", successStyle.Render("Done:"), succeeded) + if failed > 0 { + fmt.Printf(", %s", errorStyle.Render(fmt.Sprintf("%d failed", failed))) + } + fmt.Println() + + return nil +} + +func gitPull(ctx context.Context, path string) error { + cmd := exec.CommandContext(ctx, "git", "pull", "--ff-only") + cmd.Dir = path + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s", string(output)) + } + return nil +} diff --git a/cmd/core/cmd/push.go b/cmd/core/cmd/push.go new file mode 100644 index 0000000..5fc652c --- /dev/null +++ b/cmd/core/cmd/push.go @@ -0,0 +1,144 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/Snider/Core/pkg/git" + "github.com/Snider/Core/pkg/repos" + "github.com/leaanthony/clir" +) + +// AddPushCommand adds the 'push' command to the given parent command. +func AddPushCommand(parent *clir.Cli) { + var registryPath string + var force bool + + pushCmd := parent.NewSubCommand("push", "Push commits across all repos") + pushCmd.LongDescription("Pushes unpushed commits for all repos.\n" + + "Shows repos with commits to push and confirms before pushing.") + + pushCmd.StringFlag("registry", "Path to repos.yaml (auto-detected if not specified)", ®istryPath) + pushCmd.BoolFlag("force", "Skip confirmation prompt", &force) + + pushCmd.Action(func() error { + return runPush(registryPath, force) + }) +} + +func runPush(registryPath string, force bool) error { + ctx := context.Background() + + // Find or use provided registry, fall back to directory scan + var reg *repos.Registry + var err error + + if registryPath != "" { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + registryPath, err = repos.FindRegistry() + if err == nil { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + // Fallback: scan current directory + cwd, _ := os.Getwd() + reg, err = repos.ScanDirectory(cwd) + if err != nil { + return fmt.Errorf("failed to scan directory: %w", err) + } + } + } + + // Build paths and names for git operations + var paths []string + names := make(map[string]string) + + for _, repo := range reg.List() { + if repo.IsGitRepo() { + paths = append(paths, repo.Path) + names[repo.Path] = repo.Name + } + } + + if len(paths) == 0 { + fmt.Println("No git repositories found.") + return nil + } + + // Get status for all repos + statuses := git.Status(ctx, git.StatusOptions{ + Paths: paths, + Names: names, + }) + + // Find repos with unpushed commits + var aheadRepos []git.RepoStatus + for _, s := range statuses { + if s.Error == nil && s.HasUnpushed() { + aheadRepos = append(aheadRepos, s) + } + } + + if len(aheadRepos) == 0 { + fmt.Println("All repos up to date. Nothing to push.") + return nil + } + + // Show repos to push + fmt.Printf("\n%d repo(s) with unpushed commits:\n\n", len(aheadRepos)) + totalCommits := 0 + for _, s := range aheadRepos { + fmt.Printf(" %s: %s\n", + repoNameStyle.Render(s.Name), + aheadStyle.Render(fmt.Sprintf("%d commit(s)", s.Ahead)), + ) + totalCommits += s.Ahead + } + + // Confirm unless --force + if !force { + fmt.Println() + if !confirm(fmt.Sprintf("Push %d commit(s) to %d repo(s)?", totalCommits, len(aheadRepos))) { + fmt.Println("Aborted.") + return nil + } + } + + fmt.Println() + + // Push sequentially (SSH passphrase needs interaction) + var pushPaths []string + for _, s := range aheadRepos { + pushPaths = append(pushPaths, s.Path) + } + + results := git.PushMultiple(ctx, pushPaths, names) + + var succeeded, failed int + for _, r := range results { + if r.Success { + fmt.Printf(" %s %s\n", successStyle.Render("✓"), r.Name) + succeeded++ + } else { + fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), r.Name, r.Error) + failed++ + } + } + + // Summary + fmt.Println() + fmt.Printf("%s %d pushed", successStyle.Render("Done:"), succeeded) + if failed > 0 { + fmt.Printf(", %s", errorStyle.Render(fmt.Sprintf("%d failed", failed))) + } + fmt.Println() + + return nil +} diff --git a/cmd/core/cmd/reviews.go b/cmd/core/cmd/reviews.go new file mode 100644 index 0000000..25d09c0 --- /dev/null +++ b/cmd/core/cmd/reviews.go @@ -0,0 +1,269 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "sort" + "strings" + "time" + + "github.com/Snider/Core/pkg/repos" + "github.com/charmbracelet/lipgloss" + "github.com/leaanthony/clir" +) + +var ( + prNumberStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#a855f7")) // purple-500 + + prTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#e2e8f0")) // gray-200 + + prAuthorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#3b82f6")) // blue-500 + + prApprovedStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#22c55e")) // green-500 + + prChangesStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#f59e0b")) // amber-500 + + prPendingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6b7280")) // gray-500 + + prDraftStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6b7280")) // gray-500 +) + +// GitHubPR represents a GitHub pull request. +type GitHubPR struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + IsDraft bool `json:"isDraft"` + CreatedAt time.Time `json:"createdAt"` + Author struct { + Login string `json:"login"` + } `json:"author"` + ReviewDecision string `json:"reviewDecision"` + Reviews struct { + Nodes []struct { + State string `json:"state"` + Author struct { + Login string `json:"login"` + } `json:"author"` + } `json:"nodes"` + } `json:"reviews"` + URL string `json:"url"` + + // Added by us + RepoName string `json:"-"` +} + +// AddReviewsCommand adds the 'reviews' command to the given parent command. +func AddReviewsCommand(parent *clir.Cli) { + var registryPath string + var author string + var showAll bool + + reviewsCmd := parent.NewSubCommand("reviews", "List PRs needing review across all repos") + reviewsCmd.LongDescription("Fetches open PRs from GitHub for all repos in the registry.\n" + + "Shows review status (approved, changes requested, pending).\n" + + "Requires the 'gh' CLI to be installed and authenticated.") + + reviewsCmd.StringFlag("registry", "Path to repos.yaml (auto-detected if not specified)", ®istryPath) + reviewsCmd.StringFlag("author", "Filter by PR author", &author) + reviewsCmd.BoolFlag("all", "Show all PRs including drafts", &showAll) + + reviewsCmd.Action(func() error { + return runReviews(registryPath, author, showAll) + }) +} + +func runReviews(registryPath string, author string, showAll bool) error { + // Check gh is available + if _, err := exec.LookPath("gh"); err != nil { + return fmt.Errorf("'gh' CLI not found. Install from https://cli.github.com/") + } + + // Find or use provided registry, fall back to directory scan + var reg *repos.Registry + var err error + + if registryPath != "" { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + registryPath, err = repos.FindRegistry() + if err == nil { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + // Fallback: scan current directory + cwd, _ := os.Getwd() + reg, err = repos.ScanDirectory(cwd) + if err != nil { + return fmt.Errorf("failed to scan directory: %w", err) + } + } + } + + // Fetch PRs sequentially (avoid GitHub rate limits) + var allPRs []GitHubPR + var fetchErrors []error + + repoList := reg.List() + for i, repo := range repoList { + repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name) + fmt.Printf("\033[2K\r%s %d/%d %s", dimStyle.Render("Fetching"), i+1, len(repoList), repo.Name) + + prs, err := fetchPRs(repoFullName, repo.Name, author) + if err != nil { + fetchErrors = append(fetchErrors, fmt.Errorf("%s: %w", repo.Name, err)) + continue + } + + for _, pr := range prs { + // Filter drafts unless --all + if !showAll && pr.IsDraft { + continue + } + allPRs = append(allPRs, pr) + } + } + fmt.Print("\033[2K\r") // Clear progress line + + // Sort: pending review first, then by date + sort.Slice(allPRs, func(i, j int) bool { + // Pending reviews come first + iPending := allPRs[i].ReviewDecision == "" || allPRs[i].ReviewDecision == "REVIEW_REQUIRED" + jPending := allPRs[j].ReviewDecision == "" || allPRs[j].ReviewDecision == "REVIEW_REQUIRED" + if iPending != jPending { + return iPending + } + return allPRs[i].CreatedAt.After(allPRs[j].CreatedAt) + }) + + // Print PRs + if len(allPRs) == 0 { + fmt.Println("No open PRs found.") + return nil + } + + // Count by status + var pending, approved, changesRequested int + for _, pr := range allPRs { + switch pr.ReviewDecision { + case "APPROVED": + approved++ + case "CHANGES_REQUESTED": + changesRequested++ + default: + pending++ + } + } + + fmt.Println() + fmt.Printf("%d open PR(s)", len(allPRs)) + if pending > 0 { + fmt.Printf(" · %s", prPendingStyle.Render(fmt.Sprintf("%d pending", pending))) + } + if approved > 0 { + fmt.Printf(" · %s", prApprovedStyle.Render(fmt.Sprintf("%d approved", approved))) + } + if changesRequested > 0 { + fmt.Printf(" · %s", prChangesStyle.Render(fmt.Sprintf("%d changes requested", changesRequested))) + } + fmt.Println() + fmt.Println() + + for _, pr := range allPRs { + printPR(pr) + } + + // Print any errors + if len(fetchErrors) > 0 { + fmt.Println() + for _, err := range fetchErrors { + fmt.Printf("%s %s\n", errorStyle.Render("Error:"), err) + } + } + + return nil +} + +func fetchPRs(repoFullName, repoName string, author string) ([]GitHubPR, error) { + args := []string{ + "pr", "list", + "--repo", repoFullName, + "--state", "open", + "--json", "number,title,state,isDraft,createdAt,author,reviewDecision,reviews,url", + } + + if author != "" { + args = append(args, "--author", author) + } + + cmd := exec.Command("gh", args...) + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := string(exitErr.Stderr) + if strings.Contains(stderr, "no pull requests") || strings.Contains(stderr, "Could not resolve") { + return nil, nil + } + return nil, fmt.Errorf("%s", stderr) + } + return nil, err + } + + var prs []GitHubPR + if err := json.Unmarshal(output, &prs); err != nil { + return nil, err + } + + // Tag with repo name + for i := range prs { + prs[i].RepoName = repoName + } + + return prs, nil +} + +func printPR(pr GitHubPR) { + // #12 [core-php] Webhook validation + num := prNumberStyle.Render(fmt.Sprintf("#%d", pr.Number)) + repo := issueRepoStyle.Render(fmt.Sprintf("[%s]", pr.RepoName)) + title := prTitleStyle.Render(truncate(pr.Title, 50)) + author := prAuthorStyle.Render("@" + pr.Author.Login) + + // Review status + var status string + switch pr.ReviewDecision { + case "APPROVED": + status = prApprovedStyle.Render("✓ approved") + case "CHANGES_REQUESTED": + status = prChangesStyle.Render("● changes requested") + default: + status = prPendingStyle.Render("○ pending review") + } + + // Draft indicator + draft := "" + if pr.IsDraft { + draft = prDraftStyle.Render(" [draft]") + } + + age := formatAge(pr.CreatedAt) + + fmt.Printf(" %s %s %s%s %s %s %s\n", num, repo, title, draft, author, status, issueAgeStyle.Render(age)) +} diff --git a/cmd/core/cmd/root.go b/cmd/core/cmd/root.go index 98ed2df..3851f32 100644 --- a/cmd/core/cmd/root.go +++ b/cmd/core/cmd/root.go @@ -70,6 +70,16 @@ func Execute() error { AddSyncCommand(devCmd) AddBuildCommand(app) AddTviewCommand(app) + AddWorkCommand(app) + AddHealthCommand(app) + AddIssuesCommand(app) + AddReviewsCommand(app) + AddCommitCommand(app) + AddPushCommand(app) + AddPullCommand(app) + AddImpactCommand(app) + AddDocsCommand(app) + AddCICommand(app) // Run the application return app.Run() } diff --git a/cmd/core/cmd/work.go b/cmd/core/cmd/work.go new file mode 100644 index 0000000..f7dd8c4 --- /dev/null +++ b/cmd/core/cmd/work.go @@ -0,0 +1,334 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/Snider/Core/pkg/git" + "github.com/Snider/Core/pkg/repos" + "github.com/charmbracelet/lipgloss" + "github.com/leaanthony/clir" +) + +var ( + // Table styles + headerStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#3b82f6")). // blue-500 + Padding(0, 1) + + cellStyle = lipgloss.NewStyle(). + Padding(0, 1) + + dirtyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ef4444")). // red-500 + Padding(0, 1) + + aheadStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#22c55e")). // green-500 + Padding(0, 1) + + cleanStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6b7280")). // gray-500 + Padding(0, 1) + + repoNameStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#e2e8f0")). // gray-200 + Padding(0, 1) + + successStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#22c55e")). // green-500 + Bold(true) + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ef4444")). // red-500 + Bold(true) + + dimStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6b7280")) // gray-500 +) + +// AddWorkCommand adds the 'work' command to the given parent command. +func AddWorkCommand(parent *clir.Cli) { + var statusOnly bool + var autoCommit bool + var registryPath string + + workCmd := parent.NewSubCommand("work", "Multi-repo git operations") + workCmd.LongDescription("Manage git status, commits, and pushes across multiple repositories.\n\n" + + "Reads repos.yaml to discover repositories and their relationships.\n" + + "Shows status, optionally commits with Claude, and pushes changes.") + + workCmd.BoolFlag("status", "Show status only, don't push", &statusOnly) + workCmd.BoolFlag("commit", "Use Claude to commit dirty repos before pushing", &autoCommit) + workCmd.StringFlag("registry", "Path to repos.yaml (auto-detected if not specified)", ®istryPath) + + workCmd.Action(func() error { + return runWork(registryPath, statusOnly, autoCommit) + }) +} + +func runWork(registryPath string, statusOnly, autoCommit bool) error { + ctx := context.Background() + + // Find or use provided registry, fall back to directory scan + var reg *repos.Registry + var err error + + if registryPath != "" { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + fmt.Printf("%s %s\n\n", dimStyle.Render("Registry:"), registryPath) + } else { + registryPath, err = repos.FindRegistry() + if err == nil { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + fmt.Printf("%s %s\n\n", dimStyle.Render("Registry:"), registryPath) + } else { + // Fallback: scan current directory + cwd, _ := os.Getwd() + reg, err = repos.ScanDirectory(cwd) + if err != nil { + return fmt.Errorf("failed to scan directory: %w", err) + } + fmt.Printf("%s %s\n\n", dimStyle.Render("Scanning:"), cwd) + } + } + + // Build paths and names for git operations + var paths []string + names := make(map[string]string) + + for _, repo := range reg.List() { + if repo.IsGitRepo() { + paths = append(paths, repo.Path) + names[repo.Path] = repo.Name + } + } + + if len(paths) == 0 { + fmt.Println("No git repositories found.") + return nil + } + + // Get status for all repos + statuses := git.Status(ctx, git.StatusOptions{ + Paths: paths, + Names: names, + }) + + // Sort by repo name for consistent output + sort.Slice(statuses, func(i, j int) bool { + return statuses[i].Name < statuses[j].Name + }) + + // Display status table + printStatusTable(statuses) + + // Collect dirty and ahead repos + var dirtyRepos []git.RepoStatus + var aheadRepos []git.RepoStatus + + for _, s := range statuses { + if s.Error != nil { + continue + } + if s.IsDirty() { + dirtyRepos = append(dirtyRepos, s) + } + if s.HasUnpushed() { + aheadRepos = append(aheadRepos, s) + } + } + + // Auto-commit dirty repos if requested + if autoCommit && len(dirtyRepos) > 0 { + fmt.Println() + fmt.Printf("%s\n", headerStyle.Render("Committing dirty repos with Claude...")) + fmt.Println() + + for _, s := range dirtyRepos { + if err := claudeCommit(ctx, s.Path, s.Name, registryPath); err != nil { + fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), s.Name, err) + } else { + fmt.Printf(" %s %s\n", successStyle.Render("✓"), s.Name) + } + } + + // Re-check status after commits + statuses = git.Status(ctx, git.StatusOptions{ + Paths: paths, + Names: names, + }) + + // Rebuild ahead repos list + aheadRepos = nil + for _, s := range statuses { + if s.Error == nil && s.HasUnpushed() { + aheadRepos = append(aheadRepos, s) + } + } + } + + // If status only, we're done + if statusOnly { + if len(dirtyRepos) > 0 && !autoCommit { + fmt.Println() + fmt.Printf("%s\n", dimStyle.Render("Use --commit to have Claude create commits")) + } + return nil + } + + // Push repos with unpushed commits + if len(aheadRepos) == 0 { + fmt.Println() + fmt.Println("All repos up to date.") + return nil + } + + fmt.Println() + fmt.Printf("%d repo(s) with unpushed commits:\n", len(aheadRepos)) + for _, s := range aheadRepos { + fmt.Printf(" %s: %d commit(s)\n", s.Name, s.Ahead) + } + + fmt.Println() + if !confirm("Push all?") { + fmt.Println("Aborted.") + return nil + } + + fmt.Println() + + // Push sequentially (SSH passphrase needs interaction) + var pushPaths []string + for _, s := range aheadRepos { + pushPaths = append(pushPaths, s.Path) + } + + results := git.PushMultiple(ctx, pushPaths, names) + + for _, r := range results { + if r.Success { + fmt.Printf(" %s %s\n", successStyle.Render("✓"), r.Name) + } else { + fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), r.Name, r.Error) + } + } + + return nil +} + +func printStatusTable(statuses []git.RepoStatus) { + // Calculate column widths + nameWidth := 4 // "Repo" + for _, s := range statuses { + if len(s.Name) > nameWidth { + nameWidth = len(s.Name) + } + } + + // Print header with fixed-width formatting + hdrStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#3b82f6")) + fmt.Printf("%-*s %8s %9s %6s %5s\n", + nameWidth, + hdrStyle.Render("Repo"), + hdrStyle.Render("Modified"), + hdrStyle.Render("Untracked"), + hdrStyle.Render("Staged"), + hdrStyle.Render("Ahead"), + ) + + // Print separator + fmt.Println(strings.Repeat("─", nameWidth+2+10+11+8+7)) + + // Print rows + for _, s := range statuses { + if s.Error != nil { + paddedName := fmt.Sprintf("%-*s", nameWidth, s.Name) + fmt.Printf("%s %s\n", + repoNameStyle.Render(paddedName), + errorStyle.Render("error: "+s.Error.Error()), + ) + continue + } + + // Style numbers based on values + modStr := fmt.Sprintf("%d", s.Modified) + if s.Modified > 0 { + modStr = dirtyStyle.Render(modStr) + } else { + modStr = cleanStyle.Render(modStr) + } + + untrackedStr := fmt.Sprintf("%d", s.Untracked) + if s.Untracked > 0 { + untrackedStr = dirtyStyle.Render(untrackedStr) + } else { + untrackedStr = cleanStyle.Render(untrackedStr) + } + + stagedStr := fmt.Sprintf("%d", s.Staged) + if s.Staged > 0 { + stagedStr = aheadStyle.Render(stagedStr) + } else { + stagedStr = cleanStyle.Render(stagedStr) + } + + aheadStr := fmt.Sprintf("%d", s.Ahead) + if s.Ahead > 0 { + aheadStr = aheadStyle.Render(aheadStr) + } else { + aheadStr = cleanStyle.Render(aheadStr) + } + + // Pad name before styling to avoid ANSI code length issues + paddedName := fmt.Sprintf("%-*s", nameWidth, s.Name) + fmt.Printf("%s %8s %9s %6s %5s\n", + repoNameStyle.Render(paddedName), + modStr, + untrackedStr, + stagedStr, + aheadStr, + ) + } +} + +func claudeCommit(ctx context.Context, repoPath, repoName, registryPath string) error { + // Load AGENTS.md context if available + agentsPath := filepath.Join(filepath.Dir(registryPath), "AGENTS.md") + var context string + if data, err := os.ReadFile(agentsPath); err == nil { + context = string(data) + "\n\n" + } + + prompt := context + "Review the uncommitted changes and create an appropriate commit. " + + "Use Co-Authored-By: Claude Opus 4.5 . Be concise." + + cmd := exec.CommandContext(ctx, "claude", "-p", prompt, "--allowedTools", "Bash,Read,Glob,Grep") + cmd.Dir = repoPath + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + return cmd.Run() +} + +func confirm(prompt string) bool { + fmt.Printf("%s [y/N] ", prompt) + var response string + fmt.Scanln(&response) + response = strings.ToLower(strings.TrimSpace(response)) + return response == "y" || response == "yes" +} diff --git a/cmd/core/go.mod b/cmd/core/go.mod index 7dfa0be..ad51770 100644 --- a/cmd/core/go.mod +++ b/cmd/core/go.mod @@ -1,32 +1,38 @@ module github.com/Snider/Core/cmd/core -go 1.25 +go 1.25.5 require ( - github.com/charmbracelet/lipgloss v1.1.0 + github.com/Snider/Core/pkg/git v0.0.0 + github.com/Snider/Core/pkg/repos v0.0.0 + github.com/charmbracelet/lipgloss v1.0.0 github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be github.com/leaanthony/clir v1.7.0 github.com/leaanthony/debme v1.2.1 github.com/leaanthony/gosod v1.0.4 github.com/rivo/tview v0.42.0 - golang.org/x/net v0.38.0 - golang.org/x/text v0.23.0 + golang.org/x/net v0.49.0 + golang.org/x/text v0.33.0 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/x/ansi v0.4.2 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/gdamore/tcell/v2 v2.8.1 // indirect + github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/muesli/termenv v0.16.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/term v0.32.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace ( + github.com/Snider/Core => ../../ + github.com/Snider/Core/pkg/git => ../../pkg/git + github.com/Snider/Core/pkg/repos => ../../pkg/repos ) diff --git a/cmd/core/go.sum b/cmd/core/go.sum index 4080d7d..8815b0f 100644 --- a/cmd/core/go.sum +++ b/cmd/core/go.sum @@ -1,22 +1,21 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= +github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= +github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk= +github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be h1:J5BL2kskAlV9ckgEsNQXscjIaLiOYiZ75d4e94E6dcQ= github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be/go.mod h1:mk5IQ+Y0ZeO87b858TlA645sVcEcbiX6YqP98kt+7+w= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU= github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leaanthony/clir v1.7.0 h1:xiAnhl7ryPwuH3ERwPWZp/pCHk8wTeiwuAOt6MiNyAw= github.com/leaanthony/clir v1.7.0/go.mod h1:k/RBkdkFl18xkkACMCLt09bhiZnrGORoxmomeMvDpE0= github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= @@ -34,24 +33,22 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -65,8 +62,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -86,8 +83,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -97,8 +94,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -108,8 +105,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -117,3 +114,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go.work b/go.work index 83677c8..1797f65 100644 --- a/go.work +++ b/go.work @@ -2,6 +2,7 @@ go 1.25.5 use ( . + ./cmd/core ./cmd/core-gui ./cmd/core-mcp ./cmd/examples/core-static-di @@ -10,12 +11,14 @@ use ( ./pkg/core ./pkg/display ./pkg/docs + ./pkg/git ./pkg/help ./pkg/i18n ./pkg/ide ./pkg/mcp ./pkg/module ./pkg/process + ./pkg/repos ./pkg/updater ./pkg/webview ./pkg/ws diff --git a/go.work.sum b/go.work.sum index 58a376e..b31b3d2 100644 --- a/go.work.sum +++ b/go.work.sum @@ -18,8 +18,6 @@ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0 github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DataDog/zstd v1.5.5 h1:oWf5W7GtOLgp6bciQYDmhHHjdhYkALu6S/5Ni9ZgSvQ= github.com/DataDog/zstd v1.5.5/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= -github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= -github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/Ladicle/tabwriter v1.0.0 h1:DZQqPvMumBDwVNElso13afjYLNp0Z7pHqHnu0r4t9Dg= github.com/Ladicle/tabwriter v1.0.0/go.mod h1:c4MdCjxQyTbGuQO/gvqJ+IA/89UEwrsD6hUCW98dyp4= github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= @@ -35,8 +33,6 @@ github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJ github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= -github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= -github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= @@ -44,6 +40,10 @@ github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ek github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= github.com/ProtonMail/gopenpgp/v2 v2.7.1 h1:Awsg7MPc2gD3I7IFac2qE3Gdls0lZW8SzrFZ3k1oz0s= github.com/ProtonMail/gopenpgp/v2 v2.7.1/go.mod h1:/BU5gfAVwqyd8EfC3Eu7zmuhwYQpKs+cGD8M//iiaxs= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= @@ -58,8 +58,6 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atterpac/refresh v0.8.6 h1:Q5miKV2qs9jW+USw8WZ/54Zz8/RSh/bOz5U6JvvDZmM= github.com/atterpac/refresh v0.8.6/go.mod h1:fJpWySLdpbANS8Ej5OvfZVZIVvi/9bmnhTjKS5EjQes= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -108,6 +106,8 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4= github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= @@ -134,31 +134,9 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= -github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= -github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= github.com/go-git/go-git/v5 v5.13.2/go.mod h1:hWdW5P4YZRjmpGHwRH2v3zkWcNl6HeXaXQEMGb3NJ9A= github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= -github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= -github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= -github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= -github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw= -github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0= -github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= -github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= -github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= -github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= -github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= -github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= -github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= -github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= -github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= -github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= -github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= -github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= -github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= -github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -169,7 +147,6 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ= github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= @@ -216,6 +193,8 @@ github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGAR github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -232,30 +211,21 @@ github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= github.com/leaanthony/clir v1.0.4/go.mod h1:k/RBkdkFl18xkkACMCLt09bhiZnrGORoxmomeMvDpE0= -github.com/leaanthony/clir v1.7.0 h1:xiAnhl7ryPwuH3ERwPWZp/pCHk8wTeiwuAOt6MiNyAw= -github.com/leaanthony/clir v1.7.0/go.mod h1:k/RBkdkFl18xkkACMCLt09bhiZnrGORoxmomeMvDpE0= -github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= -github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= -github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= -github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= github.com/leaanthony/winicon v1.0.0 h1:ZNt5U5dY71oEoKZ97UVwJRT4e+5xo5o/ieKuHuk8NqQ= github.com/leaanthony/winicon v1.0.0/go.mod h1:en5xhijl92aphrJdmRPlh4NI1L6wq3gEm0LpXAPghjU= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/lmittmann/tint v1.0.7/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-zglob v0.0.6 h1:mP8RnmCgho4oaUYDIDn6GNxYk+qJGUs8fJLn+twYj2A= github.com/mattn/go-zglob v0.0.6/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -300,7 +270,6 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6 github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A= -github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= @@ -341,10 +310,10 @@ github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQ github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA= -github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= @@ -373,20 +342,14 @@ github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= -github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= -github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= -github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= -github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= -github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/tc-hib/winres v0.3.1 h1:CwRjEGrKdbi5CvZ4ID+iyVhgyfatxFoizjPhzez9Io4= github.com/tc-hib/winres v0.3.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= -github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= -github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/wailsapp/task/v3 v3.40.1-patched3 h1:i6O1WNdSur9CGaiMDIYGjsmj/qS4465zqv+WEs6sPRs= github.com/wailsapp/task/v3 v3.40.1-patched3/go.mod h1:jIP48r8ftoSQNlxFP4+aEnkvGQqQXqCnRi/B7ROaecE= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= @@ -396,13 +359,11 @@ github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMx github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= -github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= @@ -413,45 +374,36 @@ go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.11.1-0.20230711161743-2e82bdd1719d/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac h1:TSSpLIG4v+p0rPv1pNOQtl1I8knsO4S9trOxNMOLVP4= golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/image v0.0.0-20200430140353-33d19683fad8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -460,37 +412,31 @@ golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053 h1:dHQOQddU4YHS5gY33/6klKjq7Gp3WwMyOXGNp5nzRj8= golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE= golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU= golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE= golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 h1:E2/AqCUMZGgd73TQkxUMcMla25GB9i/5HOdLr+uH7Vo= golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= +golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA= +golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU= @@ -535,3 +481,5 @@ mvdan.cc/sh/v3 v3.10.0 h1:v9z7N1DLZ7owyLM/SXZQkBSXcwr2IGMm2LY2pmhVXj4= mvdan.cc/sh/v3 v3.10.0/go.mod h1:z/mSSVyLFGZzqb3ZIKojjyqIx/xbmz/UHdCSv9HmqXY= rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/pkg/git/git.go b/pkg/git/git.go new file mode 100644 index 0000000..5f6b886 --- /dev/null +++ b/pkg/git/git.go @@ -0,0 +1,198 @@ +// Package git provides utilities for git operations across multiple repositories. +package git + +import ( + "bytes" + "context" + "os/exec" + "strconv" + "strings" + "sync" +) + +// RepoStatus represents the git status of a single repository. +type RepoStatus struct { + Name string + Path string + Modified int + Untracked int + Staged int + Ahead int + Behind int + Branch string + Error error +} + +// IsDirty returns true if there are uncommitted changes. +func (s *RepoStatus) IsDirty() bool { + return s.Modified > 0 || s.Untracked > 0 || s.Staged > 0 +} + +// HasUnpushed returns true if there are commits to push. +func (s *RepoStatus) HasUnpushed() bool { + return s.Ahead > 0 +} + +// HasUnpulled returns true if there are commits to pull. +func (s *RepoStatus) HasUnpulled() bool { + return s.Behind > 0 +} + +// StatusOptions configures the status check. +type StatusOptions struct { + // Paths is a list of repo paths to check + Paths []string + // Names maps paths to display names + Names map[string]string +} + +// Status checks git status for multiple repositories in parallel. +func Status(ctx context.Context, opts StatusOptions) []RepoStatus { + var wg sync.WaitGroup + results := make([]RepoStatus, len(opts.Paths)) + + for i, path := range opts.Paths { + wg.Add(1) + go func(idx int, repoPath string) { + defer wg.Done() + name := opts.Names[repoPath] + if name == "" { + name = repoPath + } + results[idx] = getStatus(ctx, repoPath, name) + }(i, path) + } + + wg.Wait() + return results +} + +// getStatus gets the git status for a single repository. +func getStatus(ctx context.Context, path, name string) RepoStatus { + status := RepoStatus{ + Name: name, + Path: path, + } + + // Get current branch + branch, err := gitCommand(ctx, path, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + status.Error = err + return status + } + status.Branch = strings.TrimSpace(branch) + + // Get porcelain status + porcelain, err := gitCommand(ctx, path, "status", "--porcelain") + if err != nil { + status.Error = err + return status + } + + // Parse status output + for _, line := range strings.Split(porcelain, "\n") { + if len(line) < 2 { + continue + } + x, y := line[0], line[1] + + // Untracked + if x == '?' && y == '?' { + status.Untracked++ + continue + } + + // Staged (index has changes) + if x == 'A' || x == 'D' || x == 'R' || x == 'M' { + status.Staged++ + } + + // Modified in working tree + if y == 'M' || y == 'D' { + status.Modified++ + } + } + + // Get ahead/behind counts + ahead, behind := getAheadBehind(ctx, path) + status.Ahead = ahead + status.Behind = behind + + return status +} + +// getAheadBehind returns the number of commits ahead and behind upstream. +func getAheadBehind(ctx context.Context, path string) (ahead, behind int) { + // Try to get ahead count + aheadStr, err := gitCommand(ctx, path, "rev-list", "--count", "@{u}..HEAD") + if err == nil { + ahead, _ = strconv.Atoi(strings.TrimSpace(aheadStr)) + } + + // Try to get behind count + behindStr, err := gitCommand(ctx, path, "rev-list", "--count", "HEAD..@{u}") + if err == nil { + behind, _ = strconv.Atoi(strings.TrimSpace(behindStr)) + } + + return ahead, behind +} + +// Push pushes commits for a single repository. +func Push(ctx context.Context, path string) error { + _, err := gitCommand(ctx, path, "push") + return err +} + +// PushResult represents the result of a push operation. +type PushResult struct { + Name string + Path string + Success bool + Error error +} + +// PushMultiple pushes multiple repositories sequentially. +// Sequential because SSH passphrase prompts need user interaction. +func PushMultiple(ctx context.Context, paths []string, names map[string]string) []PushResult { + results := make([]PushResult, len(paths)) + + for i, path := range paths { + name := names[path] + if name == "" { + name = path + } + + result := PushResult{ + Name: name, + Path: path, + } + + err := Push(ctx, path) + if err != nil { + result.Error = err + } else { + result.Success = true + } + + results[i] = result + } + + return results +} + +// gitCommand runs a git command and returns stdout. +func gitCommand(ctx context.Context, dir string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = dir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", err + } + + return stdout.String(), nil +} diff --git a/pkg/git/go.mod b/pkg/git/go.mod new file mode 100644 index 0000000..fba77d6 --- /dev/null +++ b/pkg/git/go.mod @@ -0,0 +1,3 @@ +module github.com/Snider/Core/pkg/git + +go 1.25 diff --git a/pkg/repos/go.mod b/pkg/repos/go.mod new file mode 100644 index 0000000..0df25f7 --- /dev/null +++ b/pkg/repos/go.mod @@ -0,0 +1,11 @@ +module github.com/Snider/Core/pkg/repos + +go 1.25 + +require gopkg.in/yaml.v3 v3.0.1 + +require ( + github.com/kr/pretty v0.3.1 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect +) diff --git a/pkg/repos/go.sum b/pkg/repos/go.sum new file mode 100644 index 0000000..af470d5 --- /dev/null +++ b/pkg/repos/go.sum @@ -0,0 +1,5 @@ +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/pkg/repos/registry.go b/pkg/repos/registry.go new file mode 100644 index 0000000..1f39379 --- /dev/null +++ b/pkg/repos/registry.go @@ -0,0 +1,310 @@ +// Package repos provides functionality for managing multi-repo workspaces. +// It reads a repos.yaml registry file that defines repositories, their types, +// dependencies, and metadata. +package repos + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// Registry represents a collection of repositories defined in repos.yaml. +type Registry struct { + Version int `yaml:"version"` + Org string `yaml:"org"` + BasePath string `yaml:"base_path"` + Repos map[string]*Repo `yaml:"repos"` + Defaults RegistryDefaults `yaml:"defaults"` +} + +// RegistryDefaults contains default values applied to all repos. +type RegistryDefaults struct { + CI string `yaml:"ci"` + License string `yaml:"license"` + Branch string `yaml:"branch"` +} + +// RepoType indicates the role of a repository in the ecosystem. +type RepoType string + +const ( + RepoTypeFoundation RepoType = "foundation" + RepoTypeModule RepoType = "module" + RepoTypeProduct RepoType = "product" + RepoTypeTemplate RepoType = "template" +) + +// Repo represents a single repository in the registry. +type Repo struct { + Name string `yaml:"-"` // Set from map key + Type RepoType `yaml:"type"` + DependsOn []string `yaml:"depends_on"` + Description string `yaml:"description"` + Docs bool `yaml:"docs"` + CI string `yaml:"ci"` + Domain string `yaml:"domain,omitempty"` + + // Computed fields + Path string `yaml:"-"` // Full path to repo directory +} + +// LoadRegistry reads and parses a repos.yaml file. +func LoadRegistry(path string) (*Registry, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read registry file: %w", err) + } + + var reg Registry + if err := yaml.Unmarshal(data, ®); err != nil { + return nil, fmt.Errorf("failed to parse registry file: %w", err) + } + + // Expand base path + reg.BasePath = expandPath(reg.BasePath) + + // Set computed fields on each repo + for name, repo := range reg.Repos { + repo.Name = name + repo.Path = filepath.Join(reg.BasePath, name) + + // Apply defaults if not set + if repo.CI == "" { + repo.CI = reg.Defaults.CI + } + } + + return ®, nil +} + +// FindRegistry searches for repos.yaml in common locations. +// It checks: current directory, parent directories, and home directory. +func FindRegistry() (string, error) { + // Check current directory and parents + dir, err := os.Getwd() + if err != nil { + return "", err + } + + for { + candidate := filepath.Join(dir, "repos.yaml") + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + // Check home directory common locations + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + commonPaths := []string{ + filepath.Join(home, "Code", "host-uk", "repos.yaml"), + filepath.Join(home, ".config", "core", "repos.yaml"), + } + + for _, p := range commonPaths { + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + + return "", fmt.Errorf("repos.yaml not found") +} + +// ScanDirectory creates a Registry by scanning a directory for git repos. +// This is used as a fallback when no repos.yaml is found. +func ScanDirectory(dir string) (*Registry, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("failed to read directory: %w", err) + } + + reg := &Registry{ + Version: 1, + BasePath: dir, + Repos: make(map[string]*Repo), + } + + // Try to detect org from git remote + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + repoPath := filepath.Join(dir, entry.Name()) + gitPath := filepath.Join(repoPath, ".git") + + if _, err := os.Stat(gitPath); err != nil { + continue // Not a git repo + } + + repo := &Repo{ + Name: entry.Name(), + Path: repoPath, + Type: RepoTypeModule, // Default type + } + + reg.Repos[entry.Name()] = repo + + // Try to detect org from first repo's remote + if reg.Org == "" { + reg.Org = detectOrg(repoPath) + } + } + + return reg, nil +} + +// detectOrg tries to extract the GitHub org from a repo's origin remote. +func detectOrg(repoPath string) string { + // Try to read git remote + cmd := filepath.Join(repoPath, ".git", "config") + data, err := os.ReadFile(cmd) + if err != nil { + return "" + } + + // Simple parse for github.com URLs + content := string(data) + // Look for patterns like github.com:org/repo or github.com/org/repo + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "url = ") { + continue + } + url := strings.TrimPrefix(line, "url = ") + + // git@github.com:org/repo.git + if strings.Contains(url, "github.com:") { + parts := strings.Split(url, ":") + if len(parts) >= 2 { + orgRepo := strings.TrimSuffix(parts[1], ".git") + orgParts := strings.Split(orgRepo, "/") + if len(orgParts) >= 1 { + return orgParts[0] + } + } + } + + // https://github.com/org/repo.git + if strings.Contains(url, "github.com/") { + parts := strings.Split(url, "github.com/") + if len(parts) >= 2 { + orgRepo := strings.TrimSuffix(parts[1], ".git") + orgParts := strings.Split(orgRepo, "/") + if len(orgParts) >= 1 { + return orgParts[0] + } + } + } + } + + return "" +} + +// List returns all repos in the registry. +func (r *Registry) List() []*Repo { + repos := make([]*Repo, 0, len(r.Repos)) + for _, repo := range r.Repos { + repos = repos + repos = append(repos, repo) + } + return repos +} + +// Get returns a repo by name. +func (r *Registry) Get(name string) (*Repo, bool) { + repo, ok := r.Repos[name] + return repo, ok +} + +// ByType returns repos filtered by type. +func (r *Registry) ByType(t RepoType) []*Repo { + var repos []*Repo + for _, repo := range r.Repos { + if repo.Type == t { + repos = append(repos, repo) + } + } + return repos +} + +// TopologicalOrder returns repos sorted by dependency order. +// Foundation repos come first, then modules, then products. +func (r *Registry) TopologicalOrder() ([]*Repo, error) { + // Build dependency graph + visited := make(map[string]bool) + visiting := make(map[string]bool) + var result []*Repo + + var visit func(name string) error + visit = func(name string) error { + if visited[name] { + return nil + } + if visiting[name] { + return fmt.Errorf("circular dependency detected: %s", name) + } + + repo, ok := r.Repos[name] + if !ok { + return fmt.Errorf("unknown repo: %s", name) + } + + visiting[name] = true + for _, dep := range repo.DependsOn { + if err := visit(dep); err != nil { + return err + } + } + visiting[name] = false + visited[name] = true + result = append(result, repo) + return nil + } + + for name := range r.Repos { + if err := visit(name); err != nil { + return nil, err + } + } + + return result, nil +} + +// Exists checks if the repo directory exists on disk. +func (repo *Repo) Exists() bool { + info, err := os.Stat(repo.Path) + return err == nil && info.IsDir() +} + +// IsGitRepo checks if the repo directory contains a .git folder. +func (repo *Repo) IsGitRepo() bool { + gitPath := filepath.Join(repo.Path, ".git") + info, err := os.Stat(gitPath) + return err == nil && info.IsDir() +} + +// expandPath expands ~ to home directory. +func expandPath(path string) string { + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return path + } + return filepath.Join(home, path[2:]) + } + return path +} \ No newline at end of file