package commands import ( "fmt" "os" "path/filepath" "strings" "git.dws.rip/DWS/onyx/internal/core" "git.dws.rip/DWS/onyx/internal/git" "git.dws.rip/DWS/onyx/internal/models" "github.com/spf13/cobra" ) const ( // OnyxWorkspaceRef is the ref where ephemeral commits are stored OnyxWorkspaceRef = "refs/onyx/workspaces/current" // MaxCommitTitleLength is the maximum length for a commit title MaxCommitTitleLength = 72 ) // NewSaveCmd creates the save command func NewSaveCmd() *cobra.Command { var message string cmd := &cobra.Command{ Use: "save", Short: "Save the current work as a permanent commit", Long: `Converts the current ephemeral snapshot into a permanent commit in the active workstream. This is similar to 'git commit' but works with Onyx's workstream model.`, RunE: func(cmd *cobra.Command, args []string) error { return runSave(message) }, } cmd.Flags().StringVarP(&message, "message", "m", "", "Commit message (required)") cmd.MarkFlagRequired("message") return cmd } // runSave executes the save command func runSave(message string) error { // Validate the commit message if err := validateCommitMessage(message); err != nil { return err } // Get current directory cwd, err := os.Getwd() if err != nil { return fmt.Errorf("failed to get current directory: %w", err) } // Check if this is an Onyx repository if !core.IsOnyxRepo(cwd) { return fmt.Errorf("not an Onyx repository") } // Open the repository repo, err := core.Open(cwd) if err != nil { return fmt.Errorf("failed to open repository: %w", err) } defer repo.Close() // Use ExecuteWithTransaction to capture state_before and state_after err = core.ExecuteWithTransaction(repo, "save", message, func() error { return executeSave(repo, message) }) if err != nil { return err } fmt.Printf("Successfully saved commit: %s\n", message) return nil } // executeSave performs the actual save operation func executeSave(repo *core.OnyxRepository, message string) error { gitBackend := git.NewGitBackend(repo.GetGitRepo()) // 1. Read current ephemeral commit from workspace ref ephemeralCommitSHA, err := getEphemeralCommit(gitBackend) if err != nil { return fmt.Errorf("failed to get ephemeral commit: %w", err) } // 2. Get the commit object to extract the tree ephemeralCommit, err := gitBackend.GetCommit(ephemeralCommitSHA) if err != nil { return fmt.Errorf("failed to get ephemeral commit object: %w", err) } // 3. Load workstream collection workstreams, err := loadWorkstreams(repo) if err != nil { return fmt.Errorf("failed to load workstreams: %w", err) } // 4. Get the current workstream currentWorkstream, err := workstreams.GetCurrentWorkstream() if err != nil { return fmt.Errorf("no active workstream. Use 'onx new' to create one: %w", err) } // 5. Determine the parent commit var parentSHA string if !currentWorkstream.IsEmpty() { latestCommit, err := currentWorkstream.GetLatestCommit() if err != nil { return err } parentSHA = latestCommit.SHA } else { // For the first commit in the workstream, use the base branch HEAD baseBranch := currentWorkstream.BaseBranch if baseBranch == "" { baseBranch = "main" } // Try to get the base branch reference branchRef := fmt.Sprintf("refs/heads/%s", baseBranch) sha, err := gitBackend.GetRef(branchRef) if err == nil { parentSHA = sha } } // 6. Create new commit with the user's message treeHash := ephemeralCommit.TreeHash.String() commitSHA, err := gitBackend.CreateCommit(treeHash, parentSHA, message, "User") if err != nil { return fmt.Errorf("failed to create commit: %w", err) } // 7. Determine next branch number nextNumber := currentWorkstream.GetCommitCount() + 1 // 8. Create branch ref (e.g., refs/onyx/workstreams/feature-name/commit-1) branchRef := fmt.Sprintf("refs/onyx/workstreams/%s/commit-%d", currentWorkstream.Name, nextNumber) if err := gitBackend.UpdateRef(branchRef, commitSHA); err != nil { return fmt.Errorf("failed to create branch ref: %w", err) } // 9. Add commit to workstream workstreamCommit := models.NewWorkstreamCommit( commitSHA, message, "User", parentSHA, currentWorkstream.BaseBranch, branchRef, ) currentWorkstream.AddCommit(workstreamCommit) // 10. Save updated workstreams if err := saveWorkstreams(repo, workstreams); err != nil { return fmt.Errorf("failed to save workstreams: %w", err) } return nil } // getEphemeralCommit retrieves the current ephemeral commit SHA func getEphemeralCommit(gitBackend *git.GitBackend) (string, error) { sha, err := gitBackend.GetRef(OnyxWorkspaceRef) if err != nil { return "", fmt.Errorf("no ephemeral commit found. The daemon may not be running") } return sha, nil } // loadWorkstreams loads the workstream collection from .onx/workstreams.json func loadWorkstreams(repo *core.OnyxRepository) (*models.WorkstreamCollection, error) { workstreamsPath := filepath.Join(repo.GetOnyxPath(), "workstreams.json") data, err := os.ReadFile(workstreamsPath) if err != nil { return nil, fmt.Errorf("failed to read workstreams file: %w", err) } workstreams, err := models.DeserializeWorkstreamCollection(data) if err != nil { return nil, fmt.Errorf("failed to deserialize workstreams: %w", err) } return workstreams, nil } // saveWorkstreams saves the workstream collection to .onx/workstreams.json func saveWorkstreams(repo *core.OnyxRepository, workstreams *models.WorkstreamCollection) error { workstreamsPath := filepath.Join(repo.GetOnyxPath(), "workstreams.json") data, err := workstreams.Serialize() if err != nil { return fmt.Errorf("failed to serialize workstreams: %w", err) } if err := os.WriteFile(workstreamsPath, data, 0644); err != nil { return fmt.Errorf("failed to write workstreams file: %w", err) } return nil } // validateCommitMessage validates the commit message func validateCommitMessage(message string) error { // Check if message is empty if strings.TrimSpace(message) == "" { return fmt.Errorf("commit message cannot be empty") } // Split into lines lines := strings.Split(message, "\n") // Validate title (first line) title := strings.TrimSpace(lines[0]) if title == "" { return fmt.Errorf("commit message title cannot be empty") } if len(title) > MaxCommitTitleLength { return fmt.Errorf("commit message title is too long (%d characters). Maximum is %d characters", len(title), MaxCommitTitleLength) } return nil }