Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 47 additions & 19 deletions branch/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,32 +150,60 @@ func (b *Branch) GitStatus() (modified int, untracked int) {
return modified, untracked
}

// GitStats returns commits ahead of origin/main and total lines added/removed (committed + uncommitted).
// GitStats returns commits ahead of main and total lines added/removed (committed + uncommitted).
func (b *Branch) GitStats() (commits int, added int, removed int) {
if !b.Exists() || b.Name == "main" {
return 0, 0, 0
}

// Count commits ahead of origin/main
cmd := exec.Command("git", "-C", b.Path, "rev-list", "--count", "origin/main..HEAD")
out, err := cmd.Output()
if err == nil {
fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &commits)
// Try different refs to compare against
refs := []string{"origin/main", "main"}
var baseRef string
for _, ref := range refs {
cmd := exec.Command("git", "-C", b.Path, "rev-parse", "--verify", ref)
if cmd.Run() == nil {
baseRef = ref
break
}
}

// Get total diff stats vs origin/main (includes uncommitted)
// Using "origin/main" without "..." shows diff including working tree
cmd = exec.Command("git", "-C", b.Path, "diff", "--numstat", "origin/main")
out, err = cmd.Output()
if err == nil {
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 {
var a, r int
fmt.Sscanf(fields[0], "%d", &a)
fmt.Sscanf(fields[1], "%d", &r)
added += a
removed += r
if baseRef != "" {
// Count commits ahead of base
cmd := exec.Command("git", "-C", b.Path, "rev-list", "--count", baseRef+"..HEAD")
out, err := cmd.Output()
if err == nil {
fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &commits)
}

// Get total diff stats vs base (includes uncommitted)
cmd = exec.Command("git", "-C", b.Path, "diff", "--numstat", baseRef)
out, err = cmd.Output()
if err == nil {
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 {
var a, r int
fmt.Sscanf(fields[0], "%d", &a)
fmt.Sscanf(fields[1], "%d", &r)
added += a
removed += r
}
}
}
} else {
// No base ref found - just show uncommitted changes
cmd := exec.Command("git", "-C", b.Path, "diff", "--numstat", "HEAD")
out, err := cmd.Output()
if err == nil {
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 {
var a, r int
fmt.Sscanf(fields[0], "%d", &a)
fmt.Sscanf(fields[1], "%d", &r)
added += a
removed += r
}
}
}
}
Expand Down
14 changes: 4 additions & 10 deletions branch/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ func FindNextInstanceID() int {
}

// FindSourceRepo finds a repo to clone from.
// Always clones from 'main' or upstream - never from other branches to avoid inheriting changes.
func FindSourceRepo() string {
// Check DARK_SOURCE
// Check DARK_SOURCE (explicit override)
if config.DarkSource != config.DarkRoot {
gitPath := filepath.Join(config.DarkSource, ".git")
if info, err := os.Stat(gitPath); err == nil && info.IsDir() {
Expand All @@ -52,21 +53,14 @@ func FindSourceRepo() string {
}

// Check for 'main' branch (must be fully cloned - has devcontainer.json)
// Only use 'main' - never use other branches as source
mainPath := filepath.Join(config.DarkRoot, "main")
devcontainerPath := filepath.Join(mainPath, ".devcontainer", "devcontainer.json")
if _, err := os.Stat(devcontainerPath); err == nil {
return mainPath
}

// Check any existing managed branch (must be fully cloned)
for _, b := range GetManagedBranches() {
devcontainer := filepath.Join(b.Path, ".devcontainer", "devcontainer.json")
if _, err := os.Stat(devcontainer); err == nil {
return b.Path
}
}

// Fall back to GitHub
// Fall back to GitHub upstream - will clone fresh from origin/main
return "git@github.com:darklang/dark.git"
}

Expand Down
61 changes: 17 additions & 44 deletions branch/ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package branch

import (
"bufio"
"encoding/json"
"fmt"
"os"
"os/exec"
Expand All @@ -26,43 +25,6 @@ func logToFile(format string, args ...interface{}) {
f.WriteString(fmt.Sprintf("[branch] %s\n", msg))
}

// ensureClaudeSettings ensures ~/.claude/settings.json has theme set on the host
// This prevents Claude from showing the theme selection prompt on first run
func ensureClaudeSettings() {
homeDir, err := os.UserHomeDir()
if err != nil {
return
}

claudeDir := filepath.Join(homeDir, ".claude")
settingsPath := filepath.Join(claudeDir, "settings.json")

// Create directory if needed
os.MkdirAll(claudeDir, 0755)

// Read existing settings or start fresh
var settings map[string]interface{}
if data, err := os.ReadFile(settingsPath); err == nil {
json.Unmarshal(data, &settings)
}
if settings == nil {
settings = make(map[string]interface{})
}

// Check if theme is already set
if _, ok := settings["theme"]; ok {
return // Theme already configured
}

// Add theme setting
settings["theme"] = "dark"

// Write back
if data, err := json.MarshalIndent(settings, "", " "); err == nil {
os.WriteFile(settingsPath, data, 0644)
}
}

// Start starts a branch container and sets up tmux.
func Start(b *Branch) error {
return StartWithProgress(b, nil)
Expand Down Expand Up @@ -92,10 +54,6 @@ func StartWithProgress(b *Branch, onProgress func(status string)) error {
// Reset progress tracking for fresh start
ResetProgressLevel(b.Name)

// Ensure Claude settings exist on host (we mount ~/.claude into containers)
// This prevents the theme selection prompt from appearing
ensureClaudeSettings()

progress("preparing container")

// Generate override config
Expand Down Expand Up @@ -304,12 +262,27 @@ func CreateWithProgress(name string, onProgress func(status string)) (*Branch, e
// Ensure remote points to GitHub fork
exec.Command("git", "-C", b.Path, "remote", "set-url", "origin", githubFork).Run()

// Also add upstream remote pointing to darklang/dark
exec.Command("git", "-C", b.Path, "remote", "add", "upstream", "git@github.com:darklang/dark.git").Run()

// Fetch from both remotes
exec.Command("git", "-C", b.Path, "fetch", "origin").Run()
checkoutCmd := exec.Command("git", "-C", b.Path, "checkout", "-b", name, "origin/main")
exec.Command("git", "-C", b.Path, "fetch", "upstream").Run()

// Hard reset to upstream/main to ensure clean state (ignore any changes from source repo)
exec.Command("git", "-C", b.Path, "checkout", "main").Run()
exec.Command("git", "-C", b.Path, "reset", "--hard", "upstream/main").Run()

// Create new branch from clean main
checkoutCmd := exec.Command("git", "-C", b.Path, "checkout", "-b", name)
if err := checkoutCmd.Run(); err != nil {
exec.Command("git", "-C", b.Path, "checkout", "-b", name, "main").Run()
// Branch might already exist, just check it out
exec.Command("git", "-C", b.Path, "checkout", name).Run()
}

// Clean any untracked files
exec.Command("git", "-C", b.Path, "clean", "-fd").Run()

b.WriteMetadata(instanceID)
return b, nil
}
Expand Down
103 changes: 103 additions & 0 deletions cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/darklang/dark-multi/dns"
"github.com/darklang/dark-multi/inotify"
"github.com/darklang/dark-multi/proxy"
"github.com/darklang/dark-multi/queue"
"github.com/darklang/dark-multi/tui"
)

Expand Down Expand Up @@ -50,6 +51,7 @@ TUI shortcuts:
rootCmd.AddCommand(stopCmd())
rootCmd.AddCommand(rmCmd())
rootCmd.AddCommand(setForkCmd())
rootCmd.AddCommand(queueCmd())

return rootCmd
}
Expand Down Expand Up @@ -305,3 +307,104 @@ Current setting can be viewed with:
},
}
}

func queueCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "queue <action>",
Short: "Manage the task queue",
Long: `Manage the automated task queue.

Actions:
init Initialize queue with predefined tasks
ls List all tasks in queue
add Add a task (multi queue add <id> <prompt>)
status Show queue status summary
start Start the background queue processor
process Run one processing cycle (start next task if slot available)`,
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
action := args[0]
q := queue.Get()

switch action {
case "init":
fmt.Println("Initializing task queue...")
if err := queue.PopulateInitialQueue(); err != nil {
fmt.Fprintf(os.Stderr, "\033[0;31merror:\033[0m %v\n", err)
os.Exit(1)
}
tasks := q.GetAll()
fmt.Printf("\033[0;32m✓\033[0m Queue initialized with %d tasks\n", len(tasks))

// Show summary by status
ready := len(q.GetByStatus(queue.StatusReady))
needsPrompt := len(q.GetByStatus(queue.StatusNeedsPrompt))
fmt.Printf(" %d ready, %d need prompts\n", ready, needsPrompt)

case "ls":
tasks := q.GetAll()
if len(tasks) == 0 {
fmt.Println("Queue is empty. Run 'multi queue init' to populate.")
return
}

fmt.Printf("%-25s %-15s %s\n", "ID", "STATUS", "NAME")
fmt.Println("─────────────────────────────────────────────────────────────")
for _, t := range tasks {
fmt.Printf("%-25s %s %-12s %s\n", t.ID, t.Status.Icon(), t.Status.Display(), t.Name)
}

case "status":
tasks := q.GetAll()
running := q.CountRunning()
ready := len(q.GetByStatus(queue.StatusReady))
waiting := len(q.GetByStatus(queue.StatusWaiting))
done := len(q.GetByStatus(queue.StatusDone))
needsPrompt := len(q.GetByStatus(queue.StatusNeedsPrompt))
maxConcurrent := config.GetMaxConcurrent()

fmt.Printf("Queue Status:\n")
fmt.Printf(" 🔄 Running: %d / %d max\n", running, maxConcurrent)
fmt.Printf(" ⏳ Ready: %d\n", ready)
fmt.Printf(" 📝 Needs Prompt: %d\n", needsPrompt)
fmt.Printf(" ⏸️ Waiting: %d\n", waiting)
fmt.Printf(" ✅ Done: %d\n", done)
fmt.Printf(" ─────────────────\n")
fmt.Printf(" Total: %d\n", len(tasks))

case "add":
if len(args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: multi queue add <id> <prompt>")
os.Exit(1)
}
id := args[1]
prompt := args[2]
q.Add(id, id, prompt, 50)
q.Save()
fmt.Printf("\033[0;32m✓\033[0m Added task: %s\n", id)

case "start":
fmt.Println("Starting queue processor...")
queue.StartProcessor()
fmt.Println("\033[0;32m✓\033[0m Queue processor started")
fmt.Println("Press Ctrl+C to stop")
// Block forever (processor runs in background)
select {}

case "process":
fmt.Println("Processing queue once...")
if err := queue.ProcessOnce(); err != nil {
fmt.Fprintf(os.Stderr, "\033[0;31merror:\033[0m %v\n", err)
os.Exit(1)
}
fmt.Println("\033[0;32m✓\033[0m Done")

default:
fmt.Fprintf(os.Stderr, "Unknown action: %s\nUse: init, ls, status, add, start, process\n", action)
os.Exit(1)
}
},
}

return cmd
}
35 changes: 35 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,38 @@ func SetGitHubFork(url string) error {
forkFile := filepath.Join(ConfigDir, "github-fork")
return os.WriteFile(forkFile, []byte(url+"\n"), 0600)
}

// GetMaxConcurrent returns max concurrent containers from config.
func GetMaxConcurrent() int {
// Check environment first
if val := os.Getenv("DARK_MULTI_MAX_CONCURRENT"); val != "" {
if i, err := strconv.Atoi(val); err == nil && i > 0 {
return i
}
}

// Check config file
maxFile := filepath.Join(ConfigDir, "max-concurrent")
if data, err := os.ReadFile(maxFile); err == nil {
if i, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil && i > 0 {
return i
}
}

// Default to suggested based on system resources
return SuggestMaxInstances()
}

// SetMaxConcurrent saves max concurrent containers to config.
func SetMaxConcurrent(n int) error {
os.MkdirAll(ConfigDir, 0755)
maxFile := filepath.Join(ConfigDir, "max-concurrent")
return os.WriteFile(maxFile, []byte(strconv.Itoa(n)+"\n"), 0644)
}

// IsFirstRun returns true if this is the first time running multi.
func IsFirstRun() bool {
maxFile := filepath.Join(ConfigDir, "max-concurrent")
_, err := os.Stat(maxFile)
return os.IsNotExist(err)
}
Loading