From efa7272b32a256d0a33980192634666fde525edf Mon Sep 17 00:00:00 2001 From: James Arthur Date: Mon, 19 Jan 2026 21:36:46 +0000 Subject: [PATCH 1/8] feat(init): add --force flag and smart template generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modify `wisp init -t ` to handle three cases: - If .wisp/ doesn't exist: create full structure with template - If .wisp/ exists but template doesn't: create only the template - If both exist: error unless --force is used to overwrite This allows users to add new templates to already-initialized projects without recreating the entire .wisp/ structure. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cli/init.go | 103 ++++++++++++++++++++++++++------------ internal/cli/init_test.go | 20 +++++--- 2 files changed, 83 insertions(+), 40 deletions(-) diff --git a/internal/cli/init.go b/internal/cli/init.go index b377e84..94688c2 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -11,6 +11,7 @@ import ( ) var initTemplate string +var initForce bool var initCmd = &cobra.Command{ Use: "init", @@ -26,6 +27,7 @@ This command sets up: func init() { initCmd.Flags().StringVarP(&initTemplate, "template", "t", "default", "template name to create") + initCmd.Flags().BoolVarP(&initForce, "force", "f", false, "overwrite existing template") rootCmd.AddCommand(initCmd) } @@ -36,54 +38,89 @@ func runInit(cmd *cobra.Command, args []string) error { } wispDir := filepath.Join(cwd, ".wisp") + templateDir := filepath.Join(wispDir, "templates", initTemplate) - // Check if .wisp already exists - if _, err := os.Stat(wispDir); err == nil { - return fmt.Errorf(".wisp directory already exists") - } + wispExists := dirExists(wispDir) + templateExists := dirExists(templateDir) + + // Handle three cases: + // 1. .wisp/ doesn't exist -> full initialization + // 2. .wisp/ exists, template doesn't exist -> create only template + // 3. .wisp/ exists, template exists -> error unless --force - // Create directory structure - dirs := []string{ - wispDir, - filepath.Join(wispDir, "sessions"), - filepath.Join(wispDir, "templates", initTemplate), + if wispExists && templateExists && !initForce { + return fmt.Errorf("template %q already exists (use --force to overwrite)", initTemplate) } - for _, dir := range dirs { - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", dir, err) + if !wispExists { + // Full initialization + dirs := []string{ + wispDir, + filepath.Join(wispDir, "sessions"), + templateDir, } - } - // Write config.yaml - if err := writeConfigYAML(wispDir); err != nil { - return err - } + for _, dir := range dirs { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + } - // Write settings.json - if err := writeSettingsJSON(wispDir); err != nil { - return err - } + // Write config.yaml + if err := writeConfigYAML(wispDir); err != nil { + return err + } - // Write .sprite.env placeholder - if err := writeSpriteEnv(wispDir); err != nil { - return err - } + // Write settings.json + if err := writeSettingsJSON(wispDir); err != nil { + return err + } - // Write .gitignore - if err := writeGitignore(wispDir); err != nil { - return err - } + // Write .sprite.env placeholder + if err := writeSpriteEnv(wispDir); err != nil { + return err + } + + // Write .gitignore + if err := writeGitignore(wispDir); err != nil { + return err + } + + // Write template files + if err := writeTemplateFiles(wispDir, initTemplate); err != nil { + return err + } + + fmt.Printf("Initialized .wisp/ directory with %q template\n", initTemplate) + } else { + // Template-only creation (wisp already initialized) + if err := os.MkdirAll(templateDir, 0755); err != nil { + return fmt.Errorf("failed to create template directory %s: %w", templateDir, err) + } + + if err := writeTemplateFiles(wispDir, initTemplate); err != nil { + return err + } - // Write template files - if err := writeTemplateFiles(wispDir, initTemplate); err != nil { - return err + if templateExists && initForce { + fmt.Printf("Overwrote template %q\n", initTemplate) + } else { + fmt.Printf("Created template %q\n", initTemplate) + } } - fmt.Printf("Initialized .wisp/ directory with %q template\n", initTemplate) return nil } +// dirExists checks if a directory exists +func dirExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.IsDir() +} + func writeConfigYAML(wispDir string) error { content := `# Wisp configuration # See https://github.com/thruflo/wisp for documentation diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index cb9a9d4..eaad767 100644 --- a/internal/cli/init_test.go +++ b/internal/cli/init_test.go @@ -21,8 +21,9 @@ func TestInitCommand(t *testing.T) { err = os.Chdir(tmpDir) require.NoError(t, err) - // Reset template flag to default + // Reset flags to default initTemplate = "default" + initForce = false // Run init command err = runInit(initCmd, []string{}) @@ -129,8 +130,9 @@ func TestInitCommandWithCustomTemplate(t *testing.T) { err = os.Chdir(tmpDir) require.NoError(t, err) - // Set custom template name + // Set custom template name and reset force flag initTemplate = "custom" + initForce = false err = runInit(initCmd, []string{}) require.NoError(t, err) @@ -141,7 +143,7 @@ func TestInitCommandWithCustomTemplate(t *testing.T) { assertFileExists(t, filepath.Join(templateDir, "context.md")) } -func TestInitCommandFailsIfExists(t *testing.T) { +func TestInitCommandFailsIfTemplateExists(t *testing.T) { tmpDir := t.TempDir() originalDir, err := os.Getwd() require.NoError(t, err) @@ -150,17 +152,21 @@ func TestInitCommandFailsIfExists(t *testing.T) { err = os.Chdir(tmpDir) require.NoError(t, err) - // Create .wisp directory first - err = os.Mkdir(filepath.Join(tmpDir, ".wisp"), 0755) + // Create .wisp directory and template directory first + wispDir := filepath.Join(tmpDir, ".wisp") + templateDir := filepath.Join(wispDir, "templates", "default") + err = os.MkdirAll(templateDir, 0755) require.NoError(t, err) - // Reset template flag + // Reset flags initTemplate = "default" + initForce = false - // Init should fail + // Init should fail when template already exists err = runInit(initCmd, []string{}) assert.Error(t, err) assert.Contains(t, err.Error(), "already exists") + assert.Contains(t, err.Error(), "--force") } func assertDirExists(t *testing.T, path string) { From 8fbb103647c58fa4404f36b4703d02f5dba35f6b Mon Sep 17 00:00:00 2001 From: James Arthur Date: Mon, 19 Jan 2026 21:38:02 +0000 Subject: [PATCH 2/8] test(init): add tests for smart template generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests for: - Template-only creation when .wisp/ already exists - --force flag to overwrite existing templates - Template content correctness in template-only creation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cli/init_test.go | 136 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index eaad767..6c42891 100644 --- a/internal/cli/init_test.go +++ b/internal/cli/init_test.go @@ -182,3 +182,139 @@ func assertFileExists(t *testing.T, path string) { require.NoError(t, err, "file should exist: %s", path) assert.False(t, info.IsDir(), "should be a file: %s", path) } + +func TestInitTemplateOnlyCreation(t *testing.T) { + tmpDir := t.TempDir() + originalDir, err := os.Getwd() + require.NoError(t, err) + defer os.Chdir(originalDir) + + err = os.Chdir(tmpDir) + require.NoError(t, err) + + // First, do a full init with default template + initTemplate = "default" + initForce = false + err = runInit(initCmd, []string{}) + require.NoError(t, err) + + wispDir := filepath.Join(tmpDir, ".wisp") + + // Record config.yaml modification time to verify it's not rewritten + configPath := filepath.Join(wispDir, "config.yaml") + configInfo, err := os.Stat(configPath) + require.NoError(t, err) + configModTime := configInfo.ModTime() + + // Now init with a new template name (should create only the template) + initTemplate = "website" + initForce = false + err = runInit(initCmd, []string{}) + require.NoError(t, err) + + // Verify new template directory was created + newTemplateDir := filepath.Join(wispDir, "templates", "website") + assertDirExists(t, newTemplateDir) + assertFileExists(t, filepath.Join(newTemplateDir, "context.md")) + + // Verify config.yaml was NOT rewritten (same modification time) + configInfoAfter, err := os.Stat(configPath) + require.NoError(t, err) + assert.Equal(t, configModTime, configInfoAfter.ModTime(), "config.yaml should not be modified during template-only creation") + + // Verify original template still exists + assertDirExists(t, filepath.Join(wispDir, "templates", "default")) +} + +func TestInitForceOverwritesTemplate(t *testing.T) { + tmpDir := t.TempDir() + originalDir, err := os.Getwd() + require.NoError(t, err) + defer os.Chdir(originalDir) + + err = os.Chdir(tmpDir) + require.NoError(t, err) + + // First, do a full init with default template + initTemplate = "default" + initForce = false + err = runInit(initCmd, []string{}) + require.NoError(t, err) + + wispDir := filepath.Join(tmpDir, ".wisp") + templateDir := filepath.Join(wispDir, "templates", "default") + + // Modify an existing template file to verify it gets overwritten + contextPath := filepath.Join(templateDir, "context.md") + originalContent, err := os.ReadFile(contextPath) + require.NoError(t, err) + err = os.WriteFile(contextPath, []byte("modified content"), 0644) + require.NoError(t, err) + + // Try to init with default template again without --force (should fail) + initTemplate = "default" + initForce = false + err = runInit(initCmd, []string{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + + // Verify content was NOT overwritten + content, err := os.ReadFile(contextPath) + require.NoError(t, err) + assert.Equal(t, "modified content", string(content)) + + // Now init with --force (should succeed and overwrite) + initTemplate = "default" + initForce = true + err = runInit(initCmd, []string{}) + require.NoError(t, err) + + // Verify content WAS overwritten with original template content + content, err = os.ReadFile(contextPath) + require.NoError(t, err) + assert.Equal(t, string(originalContent), string(content)) +} + +func TestInitTemplateOnlyHasCorrectContent(t *testing.T) { + tmpDir := t.TempDir() + originalDir, err := os.Getwd() + require.NoError(t, err) + defer os.Chdir(originalDir) + + err = os.Chdir(tmpDir) + require.NoError(t, err) + + // Create minimal .wisp structure without templates + wispDir := filepath.Join(tmpDir, ".wisp") + err = os.MkdirAll(wispDir, 0755) + require.NoError(t, err) + + // Init with a template (template-only creation since .wisp exists) + initTemplate = "backend" + initForce = false + err = runInit(initCmd, []string{}) + require.NoError(t, err) + + // Verify all expected template files exist with proper content + templateDir := filepath.Join(wispDir, "templates", "backend") + expectedTemplates := []string{ + "context.md", + "create-tasks.md", + "update-tasks.md", + "review-tasks.md", + "iterate.md", + "generate-pr.md", + } + + for _, tmpl := range expectedTemplates { + path := filepath.Join(templateDir, tmpl) + assertFileExists(t, path) + + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.NotEmpty(t, content, "template %s should have content", tmpl) + + // Verify content is meaningful (not just empty or placeholder) + assert.True(t, len(content) > 50, "template %s should have substantial content", tmpl) + } +} From 6b592d01a3ac31dceff93d44061432bb1f754149 Mon Sep 17 00:00:00 2001 From: James Arthur Date: Mon, 19 Jan 2026 21:41:30 +0000 Subject: [PATCH 3/8] feat(config): add SiblingRepo type with ref support and backwards compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SiblingRepo struct with Repo and Ref fields - Implement UnmarshalYAML for backwards compatibility with string format - Update Session struct with Ref and Continue fields - Update all code and tests to use new SiblingRepo type 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cli/resume.go | 10 ++++----- internal/cli/resume_test.go | 4 ++-- internal/cli/start.go | 18 ++++++++++------ internal/config/loader_test.go | 4 +++- internal/config/types.go | 39 +++++++++++++++++++++++++++------- internal/config/types_test.go | 10 ++++----- internal/state/storage_test.go | 2 +- 7 files changed, 59 insertions(+), 28 deletions(-) diff --git a/internal/cli/resume.go b/internal/cli/resume.go index 117768f..07cb649 100644 --- a/internal/cli/resume.go +++ b/internal/cli/resume.go @@ -291,16 +291,16 @@ func setupSpriteForResume( // Clone sibling repos for _, sibling := range session.Siblings { - siblingParts := strings.Split(sibling, "/") + siblingParts := strings.Split(sibling.Repo, "/") if len(siblingParts) != 2 { - return "", fmt.Errorf("invalid sibling repo format %q, expected org/repo", sibling) + return "", fmt.Errorf("invalid sibling repo format %q, expected org/repo", sibling.Repo) } siblingOrg, siblingRepo := siblingParts[0], siblingParts[1] siblingPath := filepath.Join(sprite.ReposDir, siblingOrg, siblingRepo) - fmt.Printf("Cloning sibling %s...\n", sibling) - if err := CloneRepo(ctx, client, session.SpriteName, sibling, siblingPath, githubToken); err != nil { - return "", fmt.Errorf("failed to clone sibling %s: %w", sibling, err) + fmt.Printf("Cloning sibling %s...\n", sibling.Repo) + if err := CloneRepo(ctx, client, session.SpriteName, sibling.Repo, siblingPath, githubToken); err != nil { + return "", fmt.Errorf("failed to clone sibling %s: %w", sibling.Repo, err) } } diff --git a/internal/cli/resume_test.go b/internal/cli/resume_test.go index e76f295..8624cd2 100644 --- a/internal/cli/resume_test.go +++ b/internal/cli/resume_test.go @@ -40,7 +40,7 @@ func TestResumeCommand_SessionExists(t *testing.T) { session := &config.Session{ Repo: "test-org/test-repo", Spec: "docs/rfc.md", - Siblings: []string{"test-org/sibling-repo"}, + Siblings: []config.SiblingRepo{{Repo: "test-org/sibling-repo"}}, Checkpoint: "checkpoint-123", Branch: "wisp/test-feature", SpriteName: "wisp-abc123", @@ -60,7 +60,7 @@ func TestResumeCommand_SessionExists(t *testing.T) { assert.Equal(t, session.SpriteName, loaded.SpriteName) assert.Equal(t, session.Checkpoint, loaded.Checkpoint) assert.Len(t, loaded.Siblings, 1) - assert.Equal(t, "test-org/sibling-repo", loaded.Siblings[0]) + assert.Equal(t, "test-org/sibling-repo", loaded.Siblings[0].Repo) } func TestResumeCommand_SessionStatusUpdate(t *testing.T) { diff --git a/internal/cli/start.go b/internal/cli/start.go index 6acb0f2..d11987a 100644 --- a/internal/cli/start.go +++ b/internal/cli/start.go @@ -118,11 +118,17 @@ func runStart(cmd *cobra.Command, args []string) error { // Generate sprite name spriteName := sprite.GenerateSpriteName(startRepo, branch) + // Convert sibling repo strings to SiblingRepo structs + var siblings []config.SiblingRepo + for _, s := range startSiblingRepo { + siblings = append(siblings, config.SiblingRepo{Repo: s}) + } + // Create session session := &config.Session{ Repo: startRepo, Spec: startSpec, - Siblings: startSiblingRepo, + Siblings: siblings, Checkpoint: startCheckpoint, Branch: branch, SpriteName: spriteName, @@ -427,16 +433,16 @@ func SetupSprite( // Clone sibling repos for _, sibling := range session.Siblings { - siblingParts := strings.Split(sibling, "/") + siblingParts := strings.Split(sibling.Repo, "/") if len(siblingParts) != 2 { - return "", fmt.Errorf("invalid sibling repo format %q, expected org/repo", sibling) + return "", fmt.Errorf("invalid sibling repo format %q, expected org/repo", sibling.Repo) } siblingOrg, siblingRepo := siblingParts[0], siblingParts[1] siblingPath := filepath.Join(sprite.ReposDir, siblingOrg, siblingRepo) - fmt.Printf("Cloning sibling %s...\n", sibling) - if err := CloneRepo(ctx, client, session.SpriteName, sibling, siblingPath, githubToken); err != nil { - return "", fmt.Errorf("failed to clone sibling %s: %w", sibling, err) + fmt.Printf("Cloning sibling %s...\n", sibling.Repo) + if err := CloneRepo(ctx, client, session.SpriteName, sibling.Repo, siblingPath, githubToken); err != nil { + return "", fmt.Errorf("failed to clone sibling %s: %w", sibling.Repo, err) } } diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index d6856b4..8223512 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -233,7 +233,9 @@ status: running assert.Equal(t, "electric-sql/electric", session.Repo) assert.Equal(t, "docs/rfc.md", session.Spec) - assert.Equal(t, []string{"TanStack/db"}, session.Siblings) + assert.Len(t, session.Siblings, 1) + assert.Equal(t, "TanStack/db", session.Siblings[0].Repo) + assert.Equal(t, "", session.Siblings[0].Ref) assert.Equal(t, "checkpoint-123", session.Checkpoint) assert.Equal(t, "wisp/feat-auth", session.Branch) assert.Equal(t, "wisp-a1b2c3", session.SpriteName) diff --git a/internal/config/types.go b/internal/config/types.go index 7142d18..58161a0 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -32,16 +32,39 @@ type Settings struct { MCPServers map[string]MCPServer `json:"mcpServers,omitempty"` } +// SiblingRepo represents a sibling repository with optional ref. +type SiblingRepo struct { + Repo string `yaml:"repo"` + Ref string `yaml:"ref,omitempty"` +} + +// UnmarshalYAML implements yaml.Unmarshaler for backwards compatibility. +// Supports both legacy string format ("org/repo") and new struct format. +func (s *SiblingRepo) UnmarshalYAML(unmarshal func(interface{}) error) error { + // Try string first (legacy format) + var str string + if err := unmarshal(&str); err == nil { + s.Repo = str + s.Ref = "" + return nil + } + // Then try struct format + type siblingAlias SiblingRepo + return unmarshal((*siblingAlias)(s)) +} + // Session represents a .wisp/sessions//session.yaml file. type Session struct { - Repo string `yaml:"repo"` - Spec string `yaml:"spec"` - Siblings []string `yaml:"siblings,omitempty"` - Checkpoint string `yaml:"checkpoint,omitempty"` - Branch string `yaml:"branch"` - SpriteName string `yaml:"sprite_name"` - StartedAt time.Time `yaml:"started_at"` - Status string `yaml:"status"` + Repo string `yaml:"repo"` + Ref string `yaml:"ref,omitempty"` // Base ref to branch from + Spec string `yaml:"spec"` + Continue bool `yaml:"continue,omitempty"` // Continue on existing branch + Siblings []SiblingRepo `yaml:"siblings,omitempty"` // Sibling repos with optional refs + Checkpoint string `yaml:"checkpoint,omitempty"` + Branch string `yaml:"branch"` + SpriteName string `yaml:"sprite_name"` + StartedAt time.Time `yaml:"started_at"` + Status string `yaml:"status"` } // Session status values. diff --git a/internal/config/types_test.go b/internal/config/types_test.go index 86a2a1a..02f80d9 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -260,7 +260,7 @@ func TestSession_YAMLMarshal(t *testing.T) { session: Session{ Repo: "electric-sql/electric", Spec: "docs/rfc.md", - Siblings: []string{"TanStack/db"}, + Siblings: []SiblingRepo{{Repo: "TanStack/db"}}, Checkpoint: "checkpoint-123", Branch: "wisp/feat-auth", SpriteName: "wisp-a1b2c3", @@ -270,7 +270,7 @@ func TestSession_YAMLMarshal(t *testing.T) { want: `repo: electric-sql/electric spec: docs/rfc.md siblings: - - TanStack/db + - repo: TanStack/db checkpoint: checkpoint-123 branch: wisp/feat-auth sprite_name: wisp-a1b2c3 @@ -352,7 +352,7 @@ status: running want: Session{ Repo: "electric-sql/electric", Spec: "docs/rfc.md", - Siblings: []string{"TanStack/db"}, + Siblings: []SiblingRepo{{Repo: "TanStack/db"}}, Checkpoint: "checkpoint-123", Branch: "wisp/feat-auth", SpriteName: "wisp-a1b2c3", @@ -396,7 +396,7 @@ status: running want: Session{ Repo: "owner/repo", Spec: "spec.md", - Siblings: []string{"org1/repo1", "org2/repo2", "org3/repo3"}, + Siblings: []SiblingRepo{{Repo: "org1/repo1"}, {Repo: "org2/repo2"}, {Repo: "org3/repo3"}}, Branch: "wisp/multi", SpriteName: "wisp-multi", StartedAt: startTime, @@ -483,7 +483,7 @@ func TestSession_YAMLRoundTrip(t *testing.T) { session := Session{ Repo: "electric-sql/electric", Spec: "docs/rfc.md", - Siblings: []string{"TanStack/db", "other/repo"}, + Siblings: []SiblingRepo{{Repo: "TanStack/db"}, {Repo: "other/repo"}}, Checkpoint: "checkpoint-123", Branch: "wisp/feat-auth", SpriteName: "wisp-a1b2c3", diff --git a/internal/state/storage_test.go b/internal/state/storage_test.go index 766c3b0..12d604b 100644 --- a/internal/state/storage_test.go +++ b/internal/state/storage_test.go @@ -44,7 +44,7 @@ func TestStore_CreateAndGetSession(t *testing.T) { session := &config.Session{ Repo: "electric-sql/electric", Spec: "docs/rfc.md", - Siblings: []string{"TanStack/db"}, + Siblings: []config.SiblingRepo{{Repo: "TanStack/db"}}, Checkpoint: "checkpoint-v1", Branch: "wisp/feat-auth", SpriteName: "wisp-abc123", From e8507afd17c7d7fb38babfe674a00cbd8fad4d5c Mon Sep 17 00:00:00 2001 From: James Arthur Date: Mon, 19 Jan 2026 21:43:03 +0000 Subject: [PATCH 4/8] feat(config): add ParseRepoRef helper function for @ref syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parses "org/repo" or "org/repo@ref" format, returning repo and ref parts. Uses LastIndex to handle refs containing @ signs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/config/types.go | 14 ++++++- internal/config/types_test.go | 69 +++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/internal/config/types.go b/internal/config/types.go index 58161a0..6db3877 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -1,6 +1,9 @@ package config -import "time" +import ( + "strings" + "time" +) // Limits defines operational boundaries for a wisp session. type Limits struct { @@ -73,3 +76,12 @@ const ( SessionStatusStopped = "stopped" SessionStatusCompleted = "completed" ) + +// ParseRepoRef parses "org/repo" or "org/repo@ref" format. +// Returns (repo, ref) where ref is empty string if not specified. +func ParseRepoRef(s string) (repo, ref string) { + if idx := strings.LastIndex(s, "@"); idx != -1 { + return s[:idx], s[idx+1:] + } + return s, "" +} diff --git a/internal/config/types_test.go b/internal/config/types_test.go index 02f80d9..0f82d89 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -499,3 +499,72 @@ func TestSession_YAMLRoundTrip(t *testing.T) { require.NoError(t, err) assert.Equal(t, session, got) } + +func TestParseRepoRef(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantRepo string + wantRef string + }{ + { + name: "no ref", + input: "org/repo", + wantRepo: "org/repo", + wantRef: "", + }, + { + name: "with branch ref", + input: "org/repo@main", + wantRepo: "org/repo", + wantRef: "main", + }, + { + name: "with tag ref", + input: "org/repo@v1.2.0", + wantRepo: "org/repo", + wantRef: "v1.2.0", + }, + { + name: "with commit ref", + input: "org/repo@abc123def", + wantRepo: "org/repo", + wantRef: "abc123def", + }, + { + name: "with slash in ref", + input: "org/repo@feature/branch", + wantRepo: "org/repo", + wantRef: "feature/branch", + }, + { + name: "multiple @ signs (uses last one)", + input: "org/repo@feature@v2", + wantRepo: "org/repo@feature", + wantRef: "v2", + }, + { + name: "empty string", + input: "", + wantRepo: "", + wantRef: "", + }, + { + name: "just org/repo with no slash in repo", + input: "repo", + wantRepo: "repo", + wantRef: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + repo, ref := ParseRepoRef(tt.input) + assert.Equal(t, tt.wantRepo, repo) + assert.Equal(t, tt.wantRef, ref) + }) + } +} From aa61a2c5ec54ecbd8a8f14fd62dfee0fd3905475 Mon Sep 17 00:00:00 2001 From: James Arthur Date: Mon, 19 Jan 2026 21:44:15 +0000 Subject: [PATCH 5/8] feat(start): add --continue flag and @ref syntax support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add --continue flag for working on existing branches - Parse @ref syntax from --repo and --sibling-repos flags - Store Ref and Continue fields in session - Add validation: --continue requires --branch - Add validation: --continue and @ref are mutually exclusive 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cli/start.go | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/internal/cli/start.go b/internal/cli/start.go index d11987a..1e070d4 100644 --- a/internal/cli/start.go +++ b/internal/cli/start.go @@ -26,6 +26,7 @@ var ( startTemplate string startCheckpoint string startHeadless bool + startContinue bool ) // HeadlessResult is the JSON output format for headless mode. @@ -64,6 +65,7 @@ func init() { startCmd.Flags().StringVarP(&startTemplate, "template", "t", "default", "template name to use") startCmd.Flags().StringVarP(&startCheckpoint, "checkpoint", "c", "", "checkpoint ID to restore from") startCmd.Flags().BoolVar(&startHeadless, "headless", false, "run without TUI, print JSON result to stdout (for testing/CI)") + startCmd.Flags().BoolVar(&startContinue, "continue", false, "continue on existing branch instead of creating new") startCmd.MarkFlagRequired("repo") startCmd.MarkFlagRequired("spec") @@ -82,6 +84,17 @@ func runStart(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get current directory: %w", err) } + // Parse repo and ref from --repo flag (supports org/repo@ref syntax) + repo, ref := config.ParseRepoRef(startRepo) + + // Validate flag combinations + if startContinue && startBranch == "" { + return fmt.Errorf("--continue requires --branch") + } + if startContinue && ref != "" { + return fmt.Errorf("--continue and @ref are mutually exclusive") + } + // Load configuration cfg, err := config.LoadConfig(cwd) if err != nil { @@ -116,18 +129,21 @@ func runStart(cmd *cobra.Command, args []string) error { } // Generate sprite name - spriteName := sprite.GenerateSpriteName(startRepo, branch) + spriteName := sprite.GenerateSpriteName(repo, branch) - // Convert sibling repo strings to SiblingRepo structs + // Convert sibling repo strings to SiblingRepo structs (with @ref parsing) var siblings []config.SiblingRepo for _, s := range startSiblingRepo { - siblings = append(siblings, config.SiblingRepo{Repo: s}) + sibRepo, sibRef := config.ParseRepoRef(s) + siblings = append(siblings, config.SiblingRepo{Repo: sibRepo, Ref: sibRef}) } // Create session session := &config.Session{ - Repo: startRepo, + Repo: repo, + Ref: ref, Spec: startSpec, + Continue: startContinue, Siblings: siblings, Checkpoint: startCheckpoint, Branch: branch, From aacba1d01f26c33233365a866f2a9095c3a6fea4 Mon Sep 17 00:00:00 2001 From: James Arthur Date: Mon, 19 Jan 2026 21:46:13 +0000 Subject: [PATCH 6/8] feat(clone): add ref checkout support to CloneRepo and SetupSprite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ref parameter to CloneRepo for checking out specific refs - Add fetchAndCheckoutBranch for --continue mode - Add checkoutRef for branching from specific base refs - Update SetupSprite to handle ref and continue modes: - Continue mode: fetch and checkout existing branch - Ref mode: checkout base ref, then create branch from it - Default: create new branch from default branch - Update resume.go to pass sibling refs to CloneRepo 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cli/resume.go | 12 +++-- internal/cli/start.go | 99 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/internal/cli/resume.go b/internal/cli/resume.go index 07cb649..1cfb236 100644 --- a/internal/cli/resume.go +++ b/internal/cli/resume.go @@ -273,7 +273,7 @@ func setupSpriteForResume( // Clone primary repo (token embedded in URL for auth) fmt.Printf("Cloning %s...\n", session.Repo) - if err := CloneRepo(ctx, client, session.SpriteName, session.Repo, repoPath, githubToken); err != nil { + if err := CloneRepo(ctx, client, session.SpriteName, session.Repo, repoPath, githubToken, ""); err != nil { return "", fmt.Errorf("failed to clone repo: %w", err) } @@ -289,7 +289,7 @@ func setupSpriteForResume( return "", fmt.Errorf("failed to copy spec file: %w", err) } - // Clone sibling repos + // Clone sibling repos (with optional ref checkout) for _, sibling := range session.Siblings { siblingParts := strings.Split(sibling.Repo, "/") if len(siblingParts) != 2 { @@ -298,8 +298,12 @@ func setupSpriteForResume( siblingOrg, siblingRepo := siblingParts[0], siblingParts[1] siblingPath := filepath.Join(sprite.ReposDir, siblingOrg, siblingRepo) - fmt.Printf("Cloning sibling %s...\n", sibling.Repo) - if err := CloneRepo(ctx, client, session.SpriteName, sibling.Repo, siblingPath, githubToken); err != nil { + if sibling.Ref != "" { + fmt.Printf("Cloning sibling %s@%s...\n", sibling.Repo, sibling.Ref) + } else { + fmt.Printf("Cloning sibling %s...\n", sibling.Repo) + } + if err := CloneRepo(ctx, client, session.SpriteName, sibling.Repo, siblingPath, githubToken, sibling.Ref); err != nil { return "", fmt.Errorf("failed to clone sibling %s: %w", sibling.Repo, err) } } diff --git a/internal/cli/start.go b/internal/cli/start.go index 1e070d4..c3740ef 100644 --- a/internal/cli/start.go +++ b/internal/cli/start.go @@ -431,14 +431,33 @@ func SetupSprite( // Clone primary repo (token embedded in URL for auth) fmt.Printf("Cloning %s...\n", session.Repo) - if err := CloneRepo(ctx, client, session.SpriteName, session.Repo, repoPath, githubToken); err != nil { + if err := CloneRepo(ctx, client, session.SpriteName, session.Repo, repoPath, githubToken, ""); err != nil { return "", fmt.Errorf("failed to clone repo: %w", err) } - // Create and checkout branch - fmt.Printf("Creating branch %s...\n", session.Branch) - if err := CreateBranch(ctx, client, session.SpriteName, repoPath, session.Branch); err != nil { - return "", fmt.Errorf("failed to create branch: %w", err) + // Handle branch checkout based on session mode + if session.Continue { + // Continue mode: fetch and checkout existing branch + fmt.Printf("Fetching and checking out existing branch %s...\n", session.Branch) + if err := fetchAndCheckoutBranch(ctx, client, session.SpriteName, repoPath, session.Branch); err != nil { + return "", fmt.Errorf("failed to checkout existing branch: %w", err) + } + } else if session.Ref != "" { + // Ref mode: checkout base ref, then create new branch from it + fmt.Printf("Checking out base ref %s...\n", session.Ref) + if err := checkoutRef(ctx, client, session.SpriteName, repoPath, session.Ref); err != nil { + return "", fmt.Errorf("failed to checkout ref: %w", err) + } + fmt.Printf("Creating branch %s...\n", session.Branch) + if err := CreateBranch(ctx, client, session.SpriteName, repoPath, session.Branch); err != nil { + return "", fmt.Errorf("failed to create branch: %w", err) + } + } else { + // Default mode: create new branch from default branch + fmt.Printf("Creating branch %s...\n", session.Branch) + if err := CreateBranch(ctx, client, session.SpriteName, repoPath, session.Branch); err != nil { + return "", fmt.Errorf("failed to create branch: %w", err) + } } // Copy spec file from local to Sprite @@ -447,7 +466,7 @@ func SetupSprite( return "", fmt.Errorf("failed to copy spec file: %w", err) } - // Clone sibling repos + // Clone sibling repos (with optional ref checkout) for _, sibling := range session.Siblings { siblingParts := strings.Split(sibling.Repo, "/") if len(siblingParts) != 2 { @@ -456,8 +475,12 @@ func SetupSprite( siblingOrg, siblingRepo := siblingParts[0], siblingParts[1] siblingPath := filepath.Join(sprite.ReposDir, siblingOrg, siblingRepo) - fmt.Printf("Cloning sibling %s...\n", sibling.Repo) - if err := CloneRepo(ctx, client, session.SpriteName, sibling.Repo, siblingPath, githubToken); err != nil { + if sibling.Ref != "" { + fmt.Printf("Cloning sibling %s@%s...\n", sibling.Repo, sibling.Ref) + } else { + fmt.Printf("Cloning sibling %s...\n", sibling.Repo) + } + if err := CloneRepo(ctx, client, session.SpriteName, sibling.Repo, siblingPath, githubToken, sibling.Ref); err != nil { return "", fmt.Errorf("failed to clone sibling %s: %w", sibling.Repo, err) } } @@ -492,8 +515,9 @@ func SetupSprite( // CloneRepo clones a GitHub repository to the specified path on a Sprite. // If githubToken is provided, it's embedded in the clone URL for authentication. +// If ref is provided, the specified ref (branch, tag, or commit) is checked out after cloning. // Exported for testing. -func CloneRepo(ctx context.Context, client sprite.Client, spriteName, repo, destPath, githubToken string) error { +func CloneRepo(ctx context.Context, client sprite.Client, spriteName, repo, destPath, githubToken, ref string) error { // Remove destination if it exists (handles stale state from previous runs) _, _, _, _ = client.ExecuteOutput(ctx, spriteName, "", nil, "rm", "-rf", destPath) @@ -522,6 +546,21 @@ func CloneRepo(ctx context.Context, client sprite.Client, spriteName, repo, dest return fmt.Errorf("git clone failed with exit code %d: %s", exitCode, string(stderr)) } + // If ref is specified, fetch and checkout the ref + if ref != "" { + // Fetch the ref (might be a remote branch, tag, or commit) + _, _, _, _ = client.ExecuteOutput(ctx, spriteName, destPath, nil, "git", "fetch", "origin", ref) + + // Checkout the ref + _, stderr, exitCode, err := client.ExecuteOutput(ctx, spriteName, destPath, nil, "git", "checkout", ref) + if err != nil { + return fmt.Errorf("failed to checkout ref %s: %w", ref, err) + } + if exitCode != 0 { + return fmt.Errorf("git checkout %s failed with exit code %d: %s", ref, exitCode, string(stderr)) + } + } + return nil } @@ -539,6 +578,48 @@ func CreateBranch(ctx context.Context, client sprite.Client, spriteName, repoPat return nil } +// fetchAndCheckoutBranch fetches and checks out an existing remote branch. +// Used when continuing work on an existing branch (--continue mode). +func fetchAndCheckoutBranch(ctx context.Context, client sprite.Client, spriteName, repoPath, branch string) error { + // Fetch the branch from remote + _, stderr, exitCode, err := client.ExecuteOutput(ctx, spriteName, repoPath, nil, "git", "fetch", "origin", branch) + if err != nil { + return fmt.Errorf("failed to fetch branch %s: %w", branch, err) + } + if exitCode != 0 { + return fmt.Errorf("git fetch %s failed with exit code %d: %s", branch, exitCode, string(stderr)) + } + + // Checkout the branch (tracking remote) + _, stderr, exitCode, err = client.ExecuteOutput(ctx, spriteName, repoPath, nil, "git", "checkout", "-B", branch, "origin/"+branch) + if err != nil { + return fmt.Errorf("failed to checkout branch %s: %w", branch, err) + } + if exitCode != 0 { + return fmt.Errorf("git checkout %s failed with exit code %d: %s", branch, exitCode, string(stderr)) + } + + return nil +} + +// checkoutRef checks out a specific ref (branch, tag, or commit) in a repository. +// Used when creating a new branch from a specific base ref. +func checkoutRef(ctx context.Context, client sprite.Client, spriteName, repoPath, ref string) error { + // Fetch the ref first (might be a remote branch, tag, or commit) + _, _, _, _ = client.ExecuteOutput(ctx, spriteName, repoPath, nil, "git", "fetch", "origin", ref) + + // Checkout the ref + _, stderr, exitCode, err := client.ExecuteOutput(ctx, spriteName, repoPath, nil, "git", "checkout", ref) + if err != nil { + return fmt.Errorf("failed to checkout ref %s: %w", ref, err) + } + if exitCode != 0 { + return fmt.Errorf("git checkout %s failed with exit code %d: %s", ref, exitCode, string(stderr)) + } + + return nil +} + // RemoteSpecPath is the known location for the spec file on the Sprite. var RemoteSpecPath = filepath.Join(sprite.SessionDir, "spec.md") From c6514d45c295c17279f2b3b07254932fe80c386b Mon Sep 17 00:00:00 2001 From: James Arthur Date: Mon, 19 Jan 2026 21:47:23 +0000 Subject: [PATCH 7/8] test(config): add tests for SiblingRepo YAML marshaling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive tests for: - Marshaling SiblingRepo with and without ref - Unmarshaling legacy string format - Unmarshaling new struct format with and without ref - Round-trip compatibility for SiblingRepo and Session 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/config/types_test.go | 136 ++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/internal/config/types_test.go b/internal/config/types_test.go index 0f82d89..20bef03 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -568,3 +568,139 @@ func TestParseRepoRef(t *testing.T) { }) } } + +func TestSiblingRepo_YAMLMarshal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sibling SiblingRepo + want string + }{ + { + name: "with ref", + sibling: SiblingRepo{Repo: "org/repo", Ref: "v1.0.0"}, + want: "repo: org/repo\nref: v1.0.0\n", + }, + { + name: "without ref", + sibling: SiblingRepo{Repo: "org/repo"}, + want: "repo: org/repo\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + data, err := yaml.Marshal(tt.sibling) + require.NoError(t, err) + assert.Equal(t, tt.want, string(data)) + }) + } +} + +func TestSiblingRepo_YAMLUnmarshal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want SiblingRepo + wantErr bool + }{ + { + name: "legacy string format", + input: "org/repo", + want: SiblingRepo{Repo: "org/repo", Ref: ""}, + wantErr: false, + }, + { + name: "struct format with ref", + input: "repo: org/repo\nref: v1.0.0", + want: SiblingRepo{Repo: "org/repo", Ref: "v1.0.0"}, + wantErr: false, + }, + { + name: "struct format without ref", + input: "repo: org/repo", + want: SiblingRepo{Repo: "org/repo", Ref: ""}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var got SiblingRepo + err := yaml.Unmarshal([]byte(tt.input), &got) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestSiblingRepo_YAMLRoundTrip(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sibling SiblingRepo + }{ + { + name: "with ref", + sibling: SiblingRepo{Repo: "org/repo", Ref: "v1.0.0"}, + }, + { + name: "without ref", + sibling: SiblingRepo{Repo: "org/repo"}, + }, + { + name: "with branch ref", + sibling: SiblingRepo{Repo: "myorg/myrepo", Ref: "feature/branch"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + data, err := yaml.Marshal(tt.sibling) + require.NoError(t, err) + + var got SiblingRepo + err = yaml.Unmarshal(data, &got) + require.NoError(t, err) + assert.Equal(t, tt.sibling, got) + }) + } +} + +func TestSession_WithSiblingRefs_YAMLRoundTrip(t *testing.T) { + t.Parallel() + + session := Session{ + Repo: "electric-sql/electric", + Ref: "v2.0.0", + Spec: "docs/rfc.md", + Continue: true, + Siblings: []SiblingRepo{ + {Repo: "TanStack/db", Ref: "main"}, + {Repo: "other/repo"}, + }, + Branch: "wisp/feat-auth", + SpriteName: "wisp-a1b2c3", + StartedAt: time.Date(2026, 1, 16, 10, 0, 0, 0, time.UTC), + Status: SessionStatusRunning, + } + + data, err := yaml.Marshal(session) + require.NoError(t, err) + + var got Session + err = yaml.Unmarshal(data, &got) + require.NoError(t, err) + assert.Equal(t, session, got) +} From c942c3538580603dff5ba56edab3421a32b47c74 Mon Sep 17 00:00:00 2001 From: James Arthur Date: Mon, 19 Jan 2026 21:48:17 +0000 Subject: [PATCH 8/8] test(start): add tests for --continue flag registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests to verify the --continue flag is properly registered and all expected start command flags are available. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cli/start_test.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/internal/cli/start_test.go b/internal/cli/start_test.go index 7544586..61e2b8b 100644 --- a/internal/cli/start_test.go +++ b/internal/cli/start_test.go @@ -200,3 +200,24 @@ func TestHeadlessResultWithError(t *testing.T) { assert.Equal(t, "crash", parsed["reason"]) assert.Equal(t, "connection failed: timeout", parsed["error"]) } + +func TestContinueFlagRegistered(t *testing.T) { + // Verify the --continue flag is registered on the start command + flag := startCmd.Flags().Lookup("continue") + require.NotNil(t, flag, "--continue flag should be registered") + assert.Equal(t, "bool", flag.Value.Type()) + assert.Equal(t, "false", flag.DefValue) + assert.Contains(t, flag.Usage, "existing branch") +} + +func TestStartFlagsAllRegistered(t *testing.T) { + // Verify all expected flags are registered + expectedFlags := []string{ + "repo", "spec", "sibling-repos", "branch", "template", "checkpoint", "headless", "continue", + } + + for _, name := range expectedFlags { + flag := startCmd.Flags().Lookup(name) + assert.NotNil(t, flag, "flag --%s should be registered", name) + } +}