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 <repo>: 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 <noreply@anthropic.com>
This commit is contained in:
parent
091c725036
commit
f3d5fd6668
21 changed files with 2950 additions and 117 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
261
cmd/core/cmd/ci.go
Normal file
261
cmd/core/cmd/ci.go
Normal file
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
169
cmd/core/cmd/commit.go
Normal file
169
cmd/core/cmd/commit.go
Normal file
|
|
@ -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 <noreply@anthropic.com>. 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()
|
||||
}
|
||||
339
cmd/core/cmd/docs.go
Normal file
339
cmd/core/cmd/docs.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
223
cmd/core/cmd/health.go
Normal file
223
cmd/core/cmd/health.go
Normal file
|
|
@ -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
|
||||
}
|
||||
193
cmd/core/cmd/impact.go
Normal file
193
cmd/core/cmd/impact.go
Normal file
|
|
@ -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 <repo-name>")
|
||||
}
|
||||
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
|
||||
}
|
||||
259
cmd/core/cmd/issues.go
Normal file
259
cmd/core/cmd/issues.go
Normal file
|
|
@ -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)))
|
||||
}
|
||||
146
cmd/core/cmd/pull.go
Normal file
146
cmd/core/cmd/pull.go
Normal file
|
|
@ -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
|
||||
}
|
||||
144
cmd/core/cmd/push.go
Normal file
144
cmd/core/cmd/push.go
Normal file
|
|
@ -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
|
||||
}
|
||||
269
cmd/core/cmd/reviews.go
Normal file
269
cmd/core/cmd/reviews.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
334
cmd/core/cmd/work.go
Normal file
334
cmd/core/cmd/work.go
Normal file
|
|
@ -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 <noreply@anthropic.com>. 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"
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
3
go.work
3
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
|
||||
|
|
|
|||
106
go.work.sum
106
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=
|
||||
|
|
|
|||
198
pkg/git/git.go
Normal file
198
pkg/git/git.go
Normal file
|
|
@ -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
|
||||
}
|
||||
3
pkg/git/go.mod
Normal file
3
pkg/git/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module github.com/Snider/Core/pkg/git
|
||||
|
||||
go 1.25
|
||||
11
pkg/repos/go.mod
Normal file
11
pkg/repos/go.mod
Normal file
|
|
@ -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
|
||||
)
|
||||
5
pkg/repos/go.sum
Normal file
5
pkg/repos/go.sum
Normal file
|
|
@ -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=
|
||||
310
pkg/repos/registry.go
Normal file
310
pkg/repos/registry.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue