diff --git a/demo-setup.sh b/demo-setup.sh index c956cc8..6c80402 100755 --- a/demo-setup.sh +++ b/demo-setup.sh @@ -184,11 +184,12 @@ YAML )" # Render all workspaces -$FLOW render bold-creek -$FLOW render swift-pine -$FLOW render calm-ridge -$FLOW render dry-fog -$FLOW render iron-vale +# Use --reset=false to skip interactive prompt when branches already exist +$FLOW render bold-creek --reset=false +$FLOW render swift-pine --reset=false +$FLOW render calm-ridge --reset=false +$FLOW render dry-fog --reset=false +$FLOW render iron-vale --reset=false # --- Set up different statuses via local changes and marker files --- diff --git a/docs/commands/flow.md b/docs/commands/flow.md index 535ad08..90b266d 100644 --- a/docs/commands/flow.md +++ b/docs/commands/flow.md @@ -16,6 +16,7 @@ A YAML manifest defines which repos/branches belong together, and `flow render` ### SEE ALSO +* [flow archive](flow_archive.md) - Archive a workspace (remove worktrees, keep state) * [flow delete](flow_delete.md) - Delete one or more workspaces and their worktrees * [flow edit](flow_edit.md) - Open flow configuration files in editor * [flow exec](flow_exec.md) - Run a command from the workspace directory diff --git a/docs/commands/flow_archive.md b/docs/commands/flow_archive.md new file mode 100644 index 0000000..912eb88 --- /dev/null +++ b/docs/commands/flow_archive.md @@ -0,0 +1,41 @@ +## flow archive + +Archive a workspace (remove worktrees, keep state) + +### Synopsis + +Archive a workspace by removing its worktrees to free up branches, +while preserving the state file. Archived workspaces are hidden from +flow status by default (use --all to see them). + +Use --closed to archive all workspaces with "closed" status at once. + +``` +flow archive [workspace] [flags] +``` + +### Examples + +``` + flow archive my-workspace # Archive a single workspace + flow archive --closed # Archive all closed workspaces +``` + +### Options + +``` + --closed Archive all workspaces with closed status + -f, --force Skip confirmation prompt + -h, --help help for archive +``` + +### Options inherited from parent commands + +``` + -v, --verbose Enable verbose debug output +``` + +### SEE ALSO + +* [flow](flow.md) - Multi-repo workspace manager using git worktrees + diff --git a/docs/commands/flow_render.md b/docs/commands/flow_render.md index a0c9d5d..f4e7370 100644 --- a/docs/commands/flow_render.md +++ b/docs/commands/flow_render.md @@ -13,12 +13,15 @@ flow render [flags] ``` flow render calm-delta + flow render calm-delta --reset # Reset existing branches + flow render calm-delta --reset=false # Use existing branches ``` ### Options ``` - -h, --help help for render + -h, --help help for render + --reset Reset existing branches to fresh state from default branch (default true) ``` ### Options inherited from parent commands diff --git a/docs/commands/flow_status.md b/docs/commands/flow_status.md index a9bd560..dcd6fb4 100644 --- a/docs/commands/flow_status.md +++ b/docs/commands/flow_status.md @@ -10,6 +10,7 @@ Show workspace status Show the resolved status of workspaces. Without arguments, shows all workspaces with their statuses. +Archived workspaces are hidden by default; use --all to include them. With a workspace argument, shows a detailed per-repo status breakdown. ``` @@ -20,12 +21,14 @@ flow status [workspace] [flags] ``` flow status # Show all workspace statuses + flow status --all # Include archived workspaces flow status vpc-ipv6 # Show per-repo breakdown ``` ### Options ``` + -a, --all Include archived workspaces -h, --help help for status ``` diff --git a/internal/cmd/archive.go b/internal/cmd/archive.go new file mode 100644 index 0000000..2a90565 --- /dev/null +++ b/internal/cmd/archive.go @@ -0,0 +1,186 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/milldr/flow/internal/config" + "github.com/milldr/flow/internal/status" + "github.com/milldr/flow/internal/ui" + "github.com/milldr/flow/internal/workspace" + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" +) + +var errWorkspaceArgRequired = errors.New("workspace argument required (or use --closed)") + +func newArchiveCmd(svc *workspace.Service, cfg *config.Config) *cobra.Command { + var closed bool + var force bool + + cmd := &cobra.Command{ + Use: "archive [workspace]", + Short: "Archive a workspace (remove worktrees, keep state)", + Long: `Archive a workspace by removing its worktrees to free up branches, +while preserving the state file. Archived workspaces are hidden from +flow status by default (use --all to see them). + +Use --closed to archive all workspaces with "closed" status at once.`, + Args: cobra.MaximumNArgs(1), + Example: " flow archive my-workspace # Archive a single workspace\n flow archive --closed # Archive all closed workspaces", + RunE: func(cmd *cobra.Command, args []string) error { + if closed { + return runArchiveClosed(cmd.Context(), svc, cfg, force) + } + if len(args) == 0 { + return errWorkspaceArgRequired + } + return runArchiveOne(cmd.Context(), svc, args[0], force) + }, + } + + cmd.Flags().BoolVar(&closed, "closed", false, "Archive all workspaces with closed status") + cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt") + return cmd +} + +func runArchiveOne(ctx context.Context, svc *workspace.Service, idOrName string, force bool) error { + id, st, err := resolveWorkspace(svc, idOrName) + if err != nil { + return err + } + + if st.Metadata.Archived { + ui.Print("Workspace is already archived.") + return nil + } + + name := workspaceDisplayName(id, st) + + if !force { + confirmed, err := ui.Confirm(fmt.Sprintf("Archive workspace %q? This will remove worktrees and free branches.", name)) + if err != nil { + return err + } + if !confirmed { + ui.Print("Cancelled.") + return nil + } + } + + err = ui.RunWithSpinner("Archiving workspace: "+name, func(_ func(string)) error { + return svc.Archive(ctx, id) + }) + if err != nil { + return err + } + + ui.Success("Archived workspace: " + name) + return nil +} + +func runArchiveClosed(ctx context.Context, svc *workspace.Service, cfg *config.Config, force bool) error { + infos, err := svc.List() + if err != nil { + return err + } + + // Filter to non-archived workspaces only. + var candidates []workspace.Info + for _, info := range infos { + if !info.Archived { + candidates = append(candidates, info) + } + } + + if len(candidates) == 0 { + ui.Print("No active workspaces to check.") + return nil + } + + // Resolve statuses to find closed workspaces. + type closedWS struct { + id string + name string + } + var closedList []closedWS + var mu sync.Mutex + + resolver := &status.Resolver{Runner: &status.ShellRunner{}} + + err = ui.RunWithSpinner("Resolving workspace statuses...", func(_ func(string)) error { + g, gctx := errgroup.WithContext(ctx) + g.SetLimit(4) + + for _, info := range candidates { + g.Go(func() error { + spec, specErr := status.LoadWithFallback( + cfg.WorkspaceStatusSpecPath(info.ID), + cfg.StatusSpecFile, + ) + if specErr != nil { + return nil // skip workspaces without status spec + } + + st, findErr := svc.Find(info.ID) + if findErr != nil { + return nil + } + + repos := stateReposToInfo(st, cfg.WorkspacePath(info.ID)) + wsName := info.Name + if wsName == "" { + wsName = info.ID + } + result := resolver.ResolveWorkspace(gctx, spec, repos, info.ID, wsName) + if result.Status == "closed" { + mu.Lock() + closedList = append(closedList, closedWS{id: info.ID, name: wsName}) + mu.Unlock() + } + return nil + }) + } + + return g.Wait() + }) + if err != nil { + return err + } + + if len(closedList) == 0 { + ui.Print("No closed workspaces to archive.") + return nil + } + + if !force { + ui.Printf("Found %d closed workspace(s) to archive:\n", len(closedList)) + for _, ws := range closedList { + ui.Printf(" - %s\n", ws.name) + } + confirmed, err := ui.Confirm("Archive all?") + if err != nil { + return err + } + if !confirmed { + ui.Print("Cancelled.") + return nil + } + } + + var archiveErrors []error + for _, ws := range closedList { + if err := svc.Archive(ctx, ws.id); err != nil { + archiveErrors = append(archiveErrors, fmt.Errorf("archiving %s: %w", ws.name, err)) + continue + } + ui.Success("Archived: " + ws.name) + } + + if len(archiveErrors) > 0 { + return errors.Join(archiveErrors...) + } + return nil +} diff --git a/internal/cmd/render.go b/internal/cmd/render.go index 27edcc1..14c7765 100644 --- a/internal/cmd/render.go +++ b/internal/cmd/render.go @@ -1,17 +1,21 @@ package cmd import ( + "fmt" + "github.com/milldr/flow/internal/ui" "github.com/milldr/flow/internal/workspace" "github.com/spf13/cobra" ) func newRenderCmd(svc *workspace.Service) *cobra.Command { - return &cobra.Command{ + var reset bool + + cmd := &cobra.Command{ Use: "render ", Short: "Create worktrees from workspace state file", Args: cobra.ExactArgs(1), - Example: ` flow render calm-delta`, + Example: " flow render calm-delta\n flow render calm-delta --reset # Reset existing branches\n flow render calm-delta --reset=false # Use existing branches", RunE: func(cmd *cobra.Command, args []string) error { id, st, err := resolveWorkspace(svc, args[0]) if err != nil { @@ -20,8 +24,20 @@ func newRenderCmd(svc *workspace.Service) *cobra.Command { name := workspaceDisplayName(id, st) + opts := &workspace.RenderOptions{} + if cmd.Flags().Changed("reset") { + if reset { + opts.OnBranchConflict = workspace.BranchConflictReset + } else { + opts.OnBranchConflict = workspace.BranchConflictUseExisting + } + } else { + opts.OnBranchConflict = workspace.BranchConflictPrompt + opts.PromptBranchConflict = ui.ConfirmBranchReset + } + err = ui.RunWithSpinner("Rendering workspace: "+name, func(report func(string)) error { - return svc.Render(cmd.Context(), id, report) + return svc.Render(cmd.Context(), id, report, opts) }) if err != nil { return err @@ -33,10 +49,13 @@ func newRenderCmd(svc *workspace.Service) *cobra.Command { if len(svc.Config.FlowConfig.Spec.Agents) > 0 { ui.Printf(" %s\n", ui.Code("flow exec "+name)) } else { - ui.Printf(" %s\n", ui.Code("flow exec "+name+" -- ")) + ui.Printf(" %s\n", ui.Code(fmt.Sprintf("flow exec %s -- ", name))) } return nil }, } + + cmd.Flags().BoolVar(&reset, "reset", true, "Reset existing branches to fresh state from default branch") + return cmd } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 53341c8..db217fe 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -94,6 +94,7 @@ func newRootCmd() *cobra.Command { root.AddCommand(newExecCmd(svc)) root.AddCommand(newOpenCmd(svc)) root.AddCommand(newDeleteCmd(svc)) + root.AddCommand(newArchiveCmd(svc, cfg)) root.AddCommand(newResetCmd(svc, cfg)) root.AddCommand(newSyncCmd(svc)) diff --git a/internal/cmd/status.go b/internal/cmd/status.go index 66a90e2..0a62e70 100644 --- a/internal/cmd/status.go +++ b/internal/cmd/status.go @@ -19,33 +19,50 @@ import ( ) func newStatusCmd(svc *workspace.Service, cfg *config.Config) *cobra.Command { + var showAll bool + cmd := &cobra.Command{ Use: "status [workspace]", Short: "Show workspace status", Long: `Show the resolved status of workspaces. Without arguments, shows all workspaces with their statuses. +Archived workspaces are hidden by default; use --all to include them. With a workspace argument, shows a detailed per-repo status breakdown.`, Args: cobra.MaximumNArgs(1), - Example: " flow status # Show all workspace statuses\n flow status vpc-ipv6 # Show per-repo breakdown", + Example: " flow status # Show all workspace statuses\n flow status --all # Include archived workspaces\n flow status vpc-ipv6 # Show per-repo breakdown", RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { - return runStatusAll(cmd.Context(), svc, cfg) + return runStatusAll(cmd.Context(), svc, cfg, showAll) } return runStatusWorkspace(cmd.Context(), svc, cfg, args[0]) }, } + cmd.Flags().BoolVarP(&showAll, "all", "a", false, "Include archived workspaces") return cmd } -func runStatusAll(ctx context.Context, svc *workspace.Service, cfg *config.Config) error { - infos, err := svc.List() +func runStatusAll(ctx context.Context, svc *workspace.Service, cfg *config.Config, showAll bool) error { + allInfos, err := svc.List() if err != nil { return err } + // Filter out archived workspaces unless --all is set. + var infos []workspace.Info + for _, info := range allInfos { + if !showAll && info.Archived { + continue + } + infos = append(infos, info) + } + if len(infos) == 0 { + if !showAll && len(allInfos) > 0 { + ui.Print("No active workspaces. Run " + ui.Code("flow status --all") + " to include archived.") + return nil + } ui.Print("No workspaces found. Run " + ui.Code("flow init") + " to create one.") return nil } @@ -63,7 +80,7 @@ func runStatusAll(ctx context.Context, svc *workspace.Service, cfg *config.Confi } rows[i] = ui.StatusRow{ Name: name, - Repos: fmt.Sprintf("%d", info.RepoCount), + RepoNames: info.RepoNames, Created: ui.RelativeTime(info.Created), CreatedAt: info.Created, CachedStatus: statusCache[info.ID].Status, diff --git a/internal/git/git.go b/internal/git/git.go index a3b7160..f3b0677 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -19,6 +19,7 @@ type Runner interface { AddWorktreeNewBranch(ctx context.Context, bareRepo, worktreePath, newBranch, startPoint string) error RemoveWorktree(ctx context.Context, bareRepo, worktreePath string) error BranchExists(ctx context.Context, bareRepo, branch string) (bool, error) + DeleteBranch(ctx context.Context, bareRepo, branch string) error DefaultBranch(ctx context.Context, bareRepo string) (string, error) EnsureRemoteRef(ctx context.Context, bareRepo, branch string) error ResetBranch(ctx context.Context, worktreePath, ref string) error @@ -142,6 +143,12 @@ func (r *RealRunner) BranchExists(ctx context.Context, bareRepo, branch string) return out != "", nil } +// DeleteBranch force-deletes a branch in the bare repo. +func (r *RealRunner) DeleteBranch(ctx context.Context, bareRepo, branch string) error { + r.log().Debug("deleting branch", "bare_repo", bareRepo, "branch", branch) + return r.run(ctx, "-C", bareRepo, "branch", "-D", branch) +} + // DefaultBranch returns the default branch name (e.g. "main" or "master") for a bare repo. func (r *RealRunner) DefaultBranch(ctx context.Context, bareRepo string) (string, error) { // In a bare clone, HEAD points to the default branch diff --git a/internal/state/types.go b/internal/state/types.go index f8b97ac..d9310a9 100644 --- a/internal/state/types.go +++ b/internal/state/types.go @@ -15,6 +15,7 @@ type Metadata struct { Name string `yaml:"name,omitempty"` Description string `yaml:"description,omitempty"` Created string `yaml:"created"` + Archived bool `yaml:"archived,omitempty"` } // Spec defines the workspace contents. diff --git a/internal/ui/prompt.go b/internal/ui/prompt.go index 246273b..44b7e54 100644 --- a/internal/ui/prompt.go +++ b/internal/ui/prompt.go @@ -61,6 +61,40 @@ func SelectAgent(agents []AgentOption) (string, error) { return selected, err } +// ConfirmBranchReset prompts the user when a branch already exists during render. +// Returns true to reset (create fresh from default), false to use existing. +func ConfirmBranchReset(repo, branch, defaultBranch string) (bool, error) { + var choice string + err := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(fmt.Sprintf("Branch %q already exists in %s", branch, repo)). + Options( + huh.NewOption(fmt.Sprintf("Reset — create fresh branch from %s", defaultBranch), "reset"), + huh.NewOption("Use existing branch", "existing"), + ). + Value(&choice), + ), + ).Run() + if err != nil { + return false, err + } + return choice == "reset", nil +} + +// Confirm prompts the user with a yes/no question. +func Confirm(msg string) (bool, error) { + var confirm bool + err := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title(msg). + Value(&confirm), + ), + ).Run() + return confirm, err +} + // DeleteRepo holds repo display info for the delete confirmation prompt. type DeleteRepo struct { Path string diff --git a/internal/ui/status_table.go b/internal/ui/status_table.go index c1f65bc..b9d1b78 100644 --- a/internal/ui/status_table.go +++ b/internal/ui/status_table.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "sort" + "strings" "time" "github.com/charmbracelet/bubbles/spinner" @@ -16,7 +17,7 @@ import ( // StatusRow holds the static columns for one workspace row. type StatusRow struct { Name string - Repos string + RepoNames []string // short repo names for display Created string CreatedAt time.Time // used for sorting within status groups CachedStatus string // last-known status for initial sort order @@ -145,7 +146,8 @@ func (m statusTableModel) renderTable(showSpinner bool, sortStatuses []string) s } else { statusCell = StatusStyle(m.statuses[i], m.display.Colors) } - rows = append(rows, []string{row.Name, statusCell, row.Repos, row.Created}) + reposCell := strings.Join(row.RepoNames, ", ") + rows = append(rows, []string{row.Name, statusCell, reposCell, row.Created}) } t := table.New(). @@ -208,7 +210,7 @@ func runStatusTablePlain(rows []StatusRow, display StatusDisplayConfig, resolve if s == "" { s = "-" } - tableRows = append(tableRows, []string{rows[i].Name, s, rows[i].Repos, rows[i].Created}) + tableRows = append(tableRows, []string{rows[i].Name, s, strings.Join(rows[i].RepoNames, ", "), rows[i].Created}) } fmt.Println(Table(headers, tableRows)) diff --git a/internal/ui/status_table_test.go b/internal/ui/status_table_test.go index 0252e4d..aae8b4d 100644 --- a/internal/ui/status_table_test.go +++ b/internal/ui/status_table_test.go @@ -13,8 +13,8 @@ var testDisplay = StatusDisplayConfig{ func TestStatusTableModel_ResolvedMsgUpdatesRow(t *testing.T) { rows := []StatusRow{ - {Name: "ws-1", Repos: "2", Created: "1d ago"}, - {Name: "ws-2", Repos: "1", Created: "3d ago"}, + {Name: "ws-1", RepoNames: []string{"repo-a", "repo-b"}, Created: "1d ago"}, + {Name: "ws-2", RepoNames: []string{"repo-a"}, Created: "3d ago"}, } m := newStatusTableModel(rows, testDisplay) @@ -34,8 +34,8 @@ func TestStatusTableModel_ResolvedMsgUpdatesRow(t *testing.T) { func TestStatusTableModel_AllResolvedQuits(t *testing.T) { rows := []StatusRow{ - {Name: "ws-1", Repos: "1", Created: "1d ago"}, - {Name: "ws-2", Repos: "2", Created: "2d ago"}, + {Name: "ws-1", RepoNames: []string{"repo-a"}, Created: "1d ago"}, + {Name: "ws-2", RepoNames: []string{"repo-a", "repo-b"}, Created: "2d ago"}, } m := newStatusTableModel(rows, testDisplay) @@ -62,7 +62,7 @@ func TestStatusTableModel_AllResolvedQuits(t *testing.T) { func TestStatusTableModel_CtrlCQuits(t *testing.T) { rows := []StatusRow{ - {Name: "ws-1", Repos: "1", Created: "1d ago"}, + {Name: "ws-1", RepoNames: []string{"repo-a"}, Created: "1d ago"}, } m := newStatusTableModel(rows, testDisplay) @@ -79,7 +79,7 @@ func TestStatusTableModel_CtrlCQuits(t *testing.T) { func TestStatusTableModel_OutOfBoundsIgnored(t *testing.T) { rows := []StatusRow{ - {Name: "ws-1", Repos: "1", Created: "1d ago"}, + {Name: "ws-1", RepoNames: []string{"repo-a"}, Created: "1d ago"}, } m := newStatusTableModel(rows, testDisplay) @@ -93,8 +93,8 @@ func TestStatusTableModel_OutOfBoundsIgnored(t *testing.T) { func TestStatusTableModel_ViewShowsSpinnerForPending(t *testing.T) { rows := []StatusRow{ - {Name: "ws-1", Repos: "1", Created: "1d ago"}, - {Name: "ws-2", Repos: "2", Created: "2d ago"}, + {Name: "ws-1", RepoNames: []string{"repo-a"}, Created: "1d ago"}, + {Name: "ws-2", RepoNames: []string{"repo-a", "repo-b"}, Created: "2d ago"}, } m := newStatusTableModel(rows, testDisplay) @@ -118,7 +118,7 @@ func TestStatusTableModel_ViewShowsSpinnerForPending(t *testing.T) { func TestStatusTableModel_ViewAfterDone(t *testing.T) { rows := []StatusRow{ - {Name: "ws-1", Repos: "1", Created: "1d ago"}, + {Name: "ws-1", RepoNames: []string{"repo-a"}, Created: "1d ago"}, } m := newStatusTableModel(rows, testDisplay) @@ -133,8 +133,8 @@ func TestStatusTableModel_ViewAfterDone(t *testing.T) { func TestRunStatusTablePlain(t *testing.T) { rows := []StatusRow{ - {Name: "ws-1", Repos: "2", Created: "1d ago"}, - {Name: "ws-2", Repos: "1", Created: "3d ago"}, + {Name: "ws-1", RepoNames: []string{"repo-a", "repo-b"}, Created: "1d ago"}, + {Name: "ws-2", RepoNames: []string{"repo-a"}, Created: "3d ago"}, } var called bool diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index c16797a..ff087cc 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -42,6 +42,8 @@ type Info struct { Name string // metadata.name (may be empty or duplicated) Description string RepoCount int + RepoNames []string // short repo names (derived from URLs) + Archived bool Created time.Time } @@ -110,11 +112,17 @@ func (s *Service) List() ([]Info, error) { } created, _ := time.Parse(time.RFC3339, st.Metadata.Created) + repoNames := make([]string, len(st.Spec.Repos)) + for i, r := range st.Spec.Repos { + repoNames[i] = state.RepoPath(r) + } infos = append(infos, Info{ ID: entry.Name(), Name: st.Metadata.Name, Description: st.Metadata.Description, RepoCount: len(st.Spec.Repos), + RepoNames: repoNames, + Archived: st.Metadata.Archived, Created: created, }) } @@ -148,11 +156,17 @@ func (s *Service) Resolve(idOrName string) ([]Info, error) { stPath := s.Config.StatePath(idOrName) st, _ := state.Load(stPath) created, _ := time.Parse(time.RFC3339, st.Metadata.Created) + repoNames := make([]string, len(st.Spec.Repos)) + for i, r := range st.Spec.Repos { + repoNames[i] = state.RepoPath(r) + } return []Info{{ ID: idOrName, Name: st.Metadata.Name, Description: st.Metadata.Description, RepoCount: len(st.Spec.Repos), + RepoNames: repoNames, + Archived: st.Metadata.Archived, Created: created, }}, nil } @@ -179,6 +193,28 @@ func (s *Service) Resolve(idOrName string) ([]Info, error) { return matches, nil } +// BranchConflict controls what happens when a branch already exists during render. +type BranchConflict int + +const ( + // BranchConflictPrompt asks the user interactively (default). + BranchConflictPrompt BranchConflict = iota + // BranchConflictReset deletes the existing branch and creates fresh from default. + BranchConflictReset + // BranchConflictUseExisting checks out the existing branch as-is. + BranchConflictUseExisting +) + +// RenderOptions configures render behavior. +type RenderOptions struct { + // OnBranchConflict controls what to do when a branch already exists. + OnBranchConflict BranchConflict + // PromptBranchConflict is called when OnBranchConflict is BranchConflictPrompt + // and a branch already exists. It should return true to reset (create fresh) + // or false to use the existing branch. + PromptBranchConflict func(repo, branch, defaultBranch string) (reset bool, err error) +} + // repoRenderContext holds pre-computed paths for rendering a single repo. type repoRenderContext struct { index int @@ -192,7 +228,11 @@ type repoRenderContext struct { // Bare repos are fetched in parallel to ensure we always have the latest remote // state before creating or updating worktrees. // progress is called with status messages for each repo. -func (s *Service) Render(ctx context.Context, id string, progress func(msg string)) error { +func (s *Service) Render(ctx context.Context, id string, progress func(msg string), opts *RenderOptions) error { + if opts == nil { + opts = &RenderOptions{} + } + st, err := s.Find(id) if err != nil { return err @@ -244,7 +284,7 @@ func (s *Service) Render(ctx context.Context, id string, progress func(msg strin rc := &repos[i] progress(fmt.Sprintf("[%d/%d] %s", rc.index+1, total, rc.repo.URL)) - if err := s.ensureWorktree(ctx, rc, progress); err != nil { + if err := s.ensureWorktree(ctx, rc, opts, progress); err != nil { return err } } @@ -278,22 +318,47 @@ func (s *Service) ensureBareRepo(ctx context.Context, rc *repoRenderContext) err // ensureWorktree creates a new worktree or updates an existing one to the // latest remote state. -func (s *Service) ensureWorktree(ctx context.Context, rc *repoRenderContext, progress func(msg string)) error { +func (s *Service) ensureWorktree(ctx context.Context, rc *repoRenderContext, opts *RenderOptions, progress func(msg string)) error { if _, err := os.Stat(rc.worktreePath); os.IsNotExist(err) { - return s.createWorktree(ctx, rc, progress) + return s.createWorktree(ctx, rc, opts, progress) } return s.updateWorktree(ctx, rc, progress) } // createWorktree creates a new worktree, either from an existing branch or // by creating a new branch from the base. -func (s *Service) createWorktree(ctx context.Context, rc *repoRenderContext, progress func(msg string)) error { +func (s *Service) createWorktree(ctx context.Context, rc *repoRenderContext, opts *RenderOptions, progress func(msg string)) error { exists, err := s.Git.BranchExists(ctx, rc.barePath, rc.repo.Branch) if err != nil { return fmt.Errorf("checking branch for %s: %w", rc.repo.URL, err) } if exists { + shouldReset, err := s.shouldResetBranch(ctx, opts, rc.barePath, rc.repoPath, rc.repo.Branch) + if err != nil { + return err + } + + if shouldReset { + defaultBranch, err := s.resolveBaseBranch(ctx, rc) + if err != nil { + return err + } + s.log().Debug("resetting branch", "branch", rc.repo.Branch, "from", defaultBranch) + if err := s.Git.DeleteBranch(ctx, rc.barePath, rc.repo.Branch); err != nil { + return fmt.Errorf("deleting branch %s in %s: %w", rc.repo.Branch, rc.repo.URL, err) + } + if err := s.Git.EnsureRemoteRef(ctx, rc.barePath, defaultBranch); err != nil { + return fmt.Errorf("ensuring remote ref for %s: %w", rc.repo.URL, err) + } + startPoint := "origin/" + defaultBranch + if err := s.Git.AddWorktreeNewBranch(ctx, rc.barePath, rc.worktreePath, rc.repo.Branch, startPoint); err != nil { + return fmt.Errorf("creating worktree for %s: %w", rc.repo.URL, err) + } + progress(fmt.Sprintf(" └── %s (%s, reset from %s) ✓", rc.repoPath, rc.repo.Branch, defaultBranch)) + return nil + } + s.log().Debug("creating worktree from existing branch", "path", rc.worktreePath, "branch", rc.repo.Branch) if err := s.Git.AddWorktree(ctx, rc.barePath, rc.worktreePath, rc.repo.Branch); err != nil { return fmt.Errorf("creating worktree for %s: %w", rc.repo.URL, err) @@ -500,6 +565,53 @@ func (s *Service) Sync(ctx context.Context, id string, progress func(msg string) return errors.Join(errs...) } +// shouldResetBranch determines whether to reset an existing branch based on options. +func (s *Service) shouldResetBranch(ctx context.Context, opts *RenderOptions, barePath, repoPath, branch string) (bool, error) { + switch opts.OnBranchConflict { + case BranchConflictReset: + return true, nil + case BranchConflictUseExisting: + return false, nil + default: // BranchConflictPrompt + if opts.PromptBranchConflict == nil { + // No prompt callback — default to using existing + return false, nil + } + defaultBranch, err := s.Git.DefaultBranch(ctx, barePath) + if err != nil { + return false, fmt.Errorf("getting default branch: %w", err) + } + return opts.PromptBranchConflict(repoPath, branch, defaultBranch) + } +} + +// Archive removes worktrees (freeing branches) and marks the workspace as archived. +// The workspace directory and state file are preserved so it can still appear in listings. +func (s *Service) Archive(ctx context.Context, id string) error { + st, err := s.Find(id) + if err != nil { + return err + } + + wsDir := s.Config.WorkspacePath(id) + s.log().Debug("archiving workspace", "id", id, "path", wsDir) + + // Remove worktrees to free branches + for _, repo := range st.Spec.Repos { + barePath := s.Config.BareRepoPath(repo.URL) + worktreePath := filepath.Join(wsDir, state.RepoPath(repo)) + + if _, err := os.Stat(worktreePath); err == nil { + s.log().Debug("removing worktree", "path", worktreePath) + _ = s.Git.RemoveWorktree(ctx, barePath, worktreePath) + } + } + + // Mark as archived in state + st.Metadata.Archived = true + return state.Save(s.Config.StatePath(id), st) +} + // Delete removes all worktrees and the workspace directory. func (s *Service) Delete(ctx context.Context, id string) error { st, err := s.Find(id) diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index 7c6814f..749dc6c 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -94,6 +94,10 @@ func (m *mockRunner) BranchExists(_ context.Context, _, _ string) (bool, error) return m.branchExists, nil } +func (m *mockRunner) DeleteBranch(_ context.Context, _, _ string) error { + return nil +} + func (m *mockRunner) DefaultBranch(_ context.Context, _ string) (string, error) { return "main", nil } @@ -281,7 +285,7 @@ func TestRender(t *testing.T) { var messages []string err := svc.Render(ctx, "render-ws", func(msg string) { messages = append(messages, msg) - }) + }, nil) if err != nil { t.Fatalf("Render: %v", err) } @@ -301,7 +305,7 @@ func TestRender(t *testing.T) { mock.resets = nil mock.isClean = true mock.currentBranch = "main" - err = svc.Render(ctx, "render-ws", noop) + err = svc.Render(ctx, "render-ws", noop, nil) if err != nil { t.Fatalf("Render (2nd): %v", err) } @@ -336,7 +340,7 @@ func TestDelete(t *testing.T) { } // Render first to create worktrees - if err := svc.Render(ctx, "del-ws", noop); err != nil { + if err := svc.Render(ctx, "del-ws", noop, nil); err != nil { t.Fatal(err) } @@ -467,7 +471,7 @@ func TestRenderExistingWorktreeDirtySkip(t *testing.T) { } // First render creates worktree - if err := svc.Render(ctx, "dirty-ws", noop); err != nil { + if err := svc.Render(ctx, "dirty-ws", noop, nil); err != nil { t.Fatal(err) } @@ -476,7 +480,7 @@ func TestRenderExistingWorktreeDirtySkip(t *testing.T) { mock.isClean = false mock.currentBranch = "main" var messages []string - err := svc.Render(ctx, "dirty-ws", func(msg string) { messages = append(messages, msg) }) + err := svc.Render(ctx, "dirty-ws", func(msg string) { messages = append(messages, msg) }, nil) if err != nil { t.Fatalf("Render (dirty): %v", err) } @@ -509,7 +513,7 @@ func TestRenderParallelFetch(t *testing.T) { t.Fatal(err) } - err := svc.Render(ctx, "parallel-ws", noop) + err := svc.Render(ctx, "parallel-ws", noop, nil) if err != nil { t.Fatalf("Render: %v", err) } @@ -538,7 +542,7 @@ func TestRenderCloneError(t *testing.T) { } mock.cloneErr = errors.New("auth failed") - err := svc.Render(ctx, "clone-fail", noop) + err := svc.Render(ctx, "clone-fail", noop, nil) if err == nil { t.Fatal("expected error from clone failure") } @@ -559,13 +563,13 @@ func TestRenderFetchError(t *testing.T) { } // First render succeeds (clones) - if err := svc.Render(ctx, "fetch-fail", noop); err != nil { + if err := svc.Render(ctx, "fetch-fail", noop, nil); err != nil { t.Fatal(err) } // Second render fails on fetch mock.fetchErr = errors.New("network down") - err := svc.Render(ctx, "fetch-fail", noop) + err := svc.Render(ctx, "fetch-fail", noop, nil) if err == nil { t.Fatal("expected error from fetch failure") } @@ -583,7 +587,7 @@ func TestRenderAddWorktreeError(t *testing.T) { } mock.addWTErr = errors.New("branch not found") - err := svc.Render(ctx, "wt-fail", noop) + err := svc.Render(ctx, "wt-fail", noop, nil) if err == nil { t.Fatal("expected error from AddWorktree failure") } @@ -602,7 +606,7 @@ func TestRenderNewBranchUsesRemoteRef(t *testing.T) { // branchExists=false triggers the new branch path mock.branchExists = false - err := svc.Render(ctx, "remote-ref", noop) + err := svc.Render(ctx, "remote-ref", noop, nil) if err != nil { t.Fatalf("Render: %v", err) } @@ -632,7 +636,7 @@ func TestRenderNewBranchUsesBaseField(t *testing.T) { // branchExists=false triggers the new branch path mock.branchExists = false - err := svc.Render(ctx, "base-field", noop) + err := svc.Render(ctx, "base-field", noop, nil) if err != nil { t.Fatalf("Render: %v", err) } @@ -649,7 +653,7 @@ func TestRenderNotFound(t *testing.T) { svc, _ := testService(t) ctx := context.Background() - err := svc.Render(ctx, "nonexistent", noop) + err := svc.Render(ctx, "nonexistent", noop, nil) if err == nil { t.Fatal("expected error") } @@ -705,7 +709,7 @@ func TestDeleteMultipleRepos(t *testing.T) { if err := svc.Create("multi-del", st); err != nil { t.Fatal(err) } - if err := svc.Render(ctx, "multi-del", noop); err != nil { + if err := svc.Render(ctx, "multi-del", noop, nil); err != nil { t.Fatal(err) } @@ -929,7 +933,7 @@ func TestRenderCreatesClaudeFiles(t *testing.T) { if err := svc.Create("claude-ws", st); err != nil { t.Fatal(err) } - if err := svc.Render(ctx, "claude-ws", noop); err != nil { + if err := svc.Render(ctx, "claude-ws", noop, nil); err != nil { t.Fatalf("Render: %v", err) } @@ -985,7 +989,7 @@ func TestRenderBranchSwitchExistingBranch(t *testing.T) { // First render creates worktree on feat/old mock.branchExists = true - if err := svc.Render(ctx, "branch-switch", noop); err != nil { + if err := svc.Render(ctx, "branch-switch", noop, nil); err != nil { t.Fatal(err) } @@ -1001,7 +1005,7 @@ func TestRenderBranchSwitchExistingBranch(t *testing.T) { mock.branchExists = true var messages []string - err := svc.Render(ctx, "branch-switch", func(msg string) { messages = append(messages, msg) }) + err := svc.Render(ctx, "branch-switch", func(msg string) { messages = append(messages, msg) }, nil) if err != nil { t.Fatalf("Render: %v", err) } @@ -1034,7 +1038,7 @@ func TestRenderBranchSwitchNewBranch(t *testing.T) { } mock.branchExists = true - if err := svc.Render(ctx, "branch-new", noop); err != nil { + if err := svc.Render(ctx, "branch-new", noop, nil); err != nil { t.Fatal(err) } @@ -1051,7 +1055,7 @@ func TestRenderBranchSwitchNewBranch(t *testing.T) { mock.branchExists = false var messages []string - err := svc.Render(ctx, "branch-new", func(msg string) { messages = append(messages, msg) }) + err := svc.Render(ctx, "branch-new", func(msg string) { messages = append(messages, msg) }, nil) if err != nil { t.Fatalf("Render: %v", err) } @@ -1087,7 +1091,7 @@ func TestRenderBranchSwitchDirtySkip(t *testing.T) { } mock.branchExists = true - if err := svc.Render(ctx, "branch-dirty", noop); err != nil { + if err := svc.Render(ctx, "branch-dirty", noop, nil); err != nil { t.Fatal(err) } @@ -1101,7 +1105,7 @@ func TestRenderBranchSwitchDirtySkip(t *testing.T) { mock.isClean = false var messages []string - err := svc.Render(ctx, "branch-dirty", func(msg string) { messages = append(messages, msg) }) + err := svc.Render(ctx, "branch-dirty", func(msg string) { messages = append(messages, msg) }, nil) if err != nil { t.Fatalf("Render: %v", err) } @@ -1134,7 +1138,7 @@ func TestRenderBranchSameNoSwitch(t *testing.T) { } mock.branchExists = true - if err := svc.Render(ctx, "same-branch", noop); err != nil { + if err := svc.Render(ctx, "same-branch", noop, nil); err != nil { t.Fatal(err) } @@ -1143,7 +1147,7 @@ func TestRenderBranchSameNoSwitch(t *testing.T) { mock.currentBranch = "feat/x" mock.isClean = true - err := svc.Render(ctx, "same-branch", noop) + err := svc.Render(ctx, "same-branch", noop, nil) if err != nil { t.Fatalf("Render: %v", err) } @@ -1167,7 +1171,7 @@ func TestSyncCleanRebase(t *testing.T) { t.Fatal(err) } // Render to create worktree directory - if err := svc.Render(ctx, "sync-clean", noop); err != nil { + if err := svc.Render(ctx, "sync-clean", noop, nil); err != nil { t.Fatal(err) } @@ -1199,7 +1203,7 @@ func TestSyncDirtySkip(t *testing.T) { if err := svc.Create("sync-dirty", st); err != nil { t.Fatal(err) } - if err := svc.Render(ctx, "sync-dirty", noop); err != nil { + if err := svc.Render(ctx, "sync-dirty", noop, nil); err != nil { t.Fatal(err) } @@ -1224,7 +1228,7 @@ func TestSyncUsesBaseField(t *testing.T) { if err := svc.Create("sync-base", st); err != nil { t.Fatal(err) } - if err := svc.Render(ctx, "sync-base", noop); err != nil { + if err := svc.Render(ctx, "sync-base", noop, nil); err != nil { t.Fatal(err) } @@ -1277,7 +1281,7 @@ func TestSyncRebaseError(t *testing.T) { if err := svc.Create("sync-fail", st); err != nil { t.Fatal(err) } - if err := svc.Render(ctx, "sync-fail", noop); err != nil { + if err := svc.Render(ctx, "sync-fail", noop, nil); err != nil { t.Fatal(err) } @@ -1307,7 +1311,7 @@ func TestSyncMultiRepos(t *testing.T) { if err := svc.Create("sync-multi", st); err != nil { t.Fatal(err) } - if err := svc.Render(ctx, "sync-multi", noop); err != nil { + if err := svc.Render(ctx, "sync-multi", noop, nil); err != nil { t.Fatal(err) }