- Remove internal/cmd/php/ (now core/php repo) - Remove internal/cmd/ci/ and internal/cmd/sdk/ (now core/ci repo) - Remove internal/variants/ build tag system entirely - Move all 30 remaining command packages from internal/cmd/ to cmd/ - Rewrite main.go with direct imports (no more variant selection) - Update all cross-references from internal/cmd/ to cmd/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
344 lines
8.6 KiB
Go
344 lines
8.6 KiB
Go
package dev
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"sort"
|
|
"strings"
|
|
|
|
"forge.lthn.ai/core/go/pkg/agentic"
|
|
"forge.lthn.ai/core/go/pkg/cli"
|
|
"forge.lthn.ai/core/go/pkg/git"
|
|
"forge.lthn.ai/core/go/pkg/i18n"
|
|
)
|
|
|
|
// Work command flags
|
|
var (
|
|
workStatusOnly bool
|
|
workAutoCommit bool
|
|
workRegistryPath string
|
|
)
|
|
|
|
// AddWorkCommand adds the 'work' command to the given parent command.
|
|
func AddWorkCommand(parent *cli.Command) {
|
|
workCmd := &cli.Command{
|
|
Use: "work",
|
|
Short: i18n.T("cmd.dev.work.short"),
|
|
Long: i18n.T("cmd.dev.work.long"),
|
|
RunE: func(cmd *cli.Command, args []string) error {
|
|
return runWork(workRegistryPath, workStatusOnly, workAutoCommit)
|
|
},
|
|
}
|
|
|
|
workCmd.Flags().BoolVar(&workStatusOnly, "status", false, i18n.T("cmd.dev.work.flag.status"))
|
|
workCmd.Flags().BoolVar(&workAutoCommit, "commit", false, i18n.T("cmd.dev.work.flag.commit"))
|
|
workCmd.Flags().StringVar(&workRegistryPath, "registry", "", i18n.T("common.flag.registry"))
|
|
|
|
parent.AddCommand(workCmd)
|
|
}
|
|
|
|
func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
|
ctx := context.Background()
|
|
|
|
// Build worker bundle with required services
|
|
bundle, err := NewWorkBundle(WorkBundleOptions{
|
|
RegistryPath: registryPath,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Start services (registers handlers)
|
|
if err := bundle.Start(ctx); err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = bundle.Stop(ctx) }()
|
|
|
|
// Load registry and get paths
|
|
paths, names, err := func() ([]string, map[string]string, error) {
|
|
reg, _, err := loadRegistryWithConfig(registryPath)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
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
|
|
}
|
|
}
|
|
return paths, names, nil
|
|
}()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(paths) == 0 {
|
|
cli.Text(i18n.T("cmd.dev.no_git_repos"))
|
|
return nil
|
|
}
|
|
|
|
// QUERY git status
|
|
result, handled, err := bundle.Core.QUERY(git.QueryStatus{
|
|
Paths: paths,
|
|
Names: names,
|
|
})
|
|
if !handled {
|
|
return cli.Err("git service not available")
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
statuses := result.([]git.RepoStatus)
|
|
|
|
// 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 {
|
|
cli.Blank()
|
|
cli.Print("%s\n", cli.TitleStyle.Render(i18n.T("cmd.dev.commit.committing")))
|
|
cli.Blank()
|
|
|
|
for _, s := range dirtyRepos {
|
|
// PERFORM commit via agentic service
|
|
_, handled, err := bundle.Core.PERFORM(agentic.TaskCommit{
|
|
Path: s.Path,
|
|
Name: s.Name,
|
|
})
|
|
if !handled {
|
|
cli.Print(" %s %s: %s\n", warningStyle.Render("!"), s.Name, "agentic service not available")
|
|
continue
|
|
}
|
|
if err != nil {
|
|
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
|
|
} else {
|
|
cli.Print(" %s %s\n", successStyle.Render("v"), s.Name)
|
|
}
|
|
}
|
|
|
|
// Re-QUERY status after commits
|
|
result, _, _ = bundle.Core.QUERY(git.QueryStatus{
|
|
Paths: paths,
|
|
Names: names,
|
|
})
|
|
statuses = result.([]git.RepoStatus)
|
|
|
|
// 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 {
|
|
cli.Blank()
|
|
cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.dev.work.use_commit_flag")))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Push repos with unpushed commits
|
|
if len(aheadRepos) == 0 {
|
|
cli.Blank()
|
|
cli.Text(i18n.T("cmd.dev.work.all_up_to_date"))
|
|
return nil
|
|
}
|
|
|
|
cli.Blank()
|
|
cli.Print("%s\n", i18n.T("common.count.repos_unpushed", map[string]interface{}{"Count": len(aheadRepos)}))
|
|
for _, s := range aheadRepos {
|
|
cli.Print(" %s: %s\n", s.Name, i18n.T("common.count.commits", map[string]interface{}{"Count": s.Ahead}))
|
|
}
|
|
|
|
cli.Blank()
|
|
if !cli.Confirm(i18n.T("cmd.dev.push.confirm")) {
|
|
cli.Text(i18n.T("cli.aborted"))
|
|
return nil
|
|
}
|
|
|
|
cli.Blank()
|
|
|
|
// PERFORM push for each repo
|
|
var divergedRepos []git.RepoStatus
|
|
|
|
for _, s := range aheadRepos {
|
|
_, handled, err := bundle.Core.PERFORM(git.TaskPush{
|
|
Path: s.Path,
|
|
Name: s.Name,
|
|
})
|
|
if !handled {
|
|
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, "git service not available")
|
|
continue
|
|
}
|
|
if err != nil {
|
|
if git.IsNonFastForward(err) {
|
|
cli.Print(" %s %s: %s\n", warningStyle.Render("!"), s.Name, i18n.T("cmd.dev.push.diverged"))
|
|
divergedRepos = append(divergedRepos, s)
|
|
} else {
|
|
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
|
|
}
|
|
} else {
|
|
cli.Print(" %s %s\n", successStyle.Render("v"), s.Name)
|
|
}
|
|
}
|
|
|
|
// Handle diverged repos - offer to pull and retry
|
|
if len(divergedRepos) > 0 {
|
|
cli.Blank()
|
|
cli.Print("%s\n", i18n.T("cmd.dev.push.diverged_help"))
|
|
if cli.Confirm(i18n.T("cmd.dev.push.pull_and_retry")) {
|
|
cli.Blank()
|
|
for _, s := range divergedRepos {
|
|
cli.Print(" %s %s...\n", dimStyle.Render("↓"), s.Name)
|
|
|
|
// PERFORM pull
|
|
_, _, err := bundle.Core.PERFORM(git.TaskPull{Path: s.Path, Name: s.Name})
|
|
if err != nil {
|
|
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
|
|
continue
|
|
}
|
|
|
|
cli.Print(" %s %s...\n", dimStyle.Render("↑"), s.Name)
|
|
|
|
// PERFORM push
|
|
_, _, err = bundle.Core.PERFORM(git.TaskPush{Path: s.Path, Name: s.Name})
|
|
if err != nil {
|
|
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
|
|
continue
|
|
}
|
|
|
|
cli.Print(" %s %s\n", successStyle.Render("v"), s.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
cli.Print("%-*s %8s %9s %6s %5s\n",
|
|
nameWidth,
|
|
cli.TitleStyle.Render(i18n.Label("repo")),
|
|
cli.TitleStyle.Render(i18n.T("cmd.dev.work.table_modified")),
|
|
cli.TitleStyle.Render(i18n.T("cmd.dev.work.table_untracked")),
|
|
cli.TitleStyle.Render(i18n.T("cmd.dev.work.table_staged")),
|
|
cli.TitleStyle.Render(i18n.T("cmd.dev.work.table_ahead")),
|
|
)
|
|
|
|
// Print separator
|
|
cli.Text(strings.Repeat("-", nameWidth+2+10+11+8+7))
|
|
|
|
// Print rows
|
|
for _, s := range statuses {
|
|
if s.Error != nil {
|
|
paddedName := cli.Sprintf("%-*s", nameWidth, s.Name)
|
|
cli.Print("%s %s\n",
|
|
repoNameStyle.Render(paddedName),
|
|
errorStyle.Render(i18n.T("cmd.dev.work.error_prefix")+" "+s.Error.Error()),
|
|
)
|
|
continue
|
|
}
|
|
|
|
// Style numbers based on values
|
|
modStr := cli.Sprintf("%d", s.Modified)
|
|
if s.Modified > 0 {
|
|
modStr = dirtyStyle.Render(modStr)
|
|
} else {
|
|
modStr = cleanStyle.Render(modStr)
|
|
}
|
|
|
|
untrackedStr := cli.Sprintf("%d", s.Untracked)
|
|
if s.Untracked > 0 {
|
|
untrackedStr = dirtyStyle.Render(untrackedStr)
|
|
} else {
|
|
untrackedStr = cleanStyle.Render(untrackedStr)
|
|
}
|
|
|
|
stagedStr := cli.Sprintf("%d", s.Staged)
|
|
if s.Staged > 0 {
|
|
stagedStr = aheadStyle.Render(stagedStr)
|
|
} else {
|
|
stagedStr = cleanStyle.Render(stagedStr)
|
|
}
|
|
|
|
aheadStr := cli.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 := cli.Sprintf("%-*s", nameWidth, s.Name)
|
|
cli.Print("%s %8s %9s %6s %5s\n",
|
|
repoNameStyle.Render(paddedName),
|
|
modStr,
|
|
untrackedStr,
|
|
stagedStr,
|
|
aheadStr,
|
|
)
|
|
}
|
|
}
|
|
|
|
// claudeCommit shells out to claude for committing (legacy helper for other commands)
|
|
func claudeCommit(ctx context.Context, repoPath, repoName, registryPath string) error {
|
|
prompt := agentic.Prompt("commit")
|
|
|
|
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()
|
|
}
|
|
|
|
// claudeEditCommit shells out to claude with edit permissions (legacy helper)
|
|
func claudeEditCommit(ctx context.Context, repoPath, repoName, registryPath string) error {
|
|
prompt := agentic.Prompt("commit")
|
|
|
|
cmd := exec.CommandContext(ctx, "claude", "-p", prompt, "--allowedTools", "Bash,Read,Write,Edit,Glob,Grep")
|
|
cmd.Dir = repoPath
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Stdin = os.Stdin
|
|
|
|
return cmd.Run()
|
|
}
|