package commands import ( "fmt" "os" "strings" "git.dws.rip/DWS/onyx/internal/core" "git.dws.rip/DWS/onyx/internal/git" gogit "github.com/go-git/go-git/v5" "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()) gitRepo := repo.GetGitRepo() // 1. Try to read current ephemeral commit from workspace ref ephemeralCommitSHA, err := getEphemeralCommit(gitBackend) var treeHash string if err != nil { // No ephemeral commit yet - this is the first commit or daemon isn't running // Use worktree to stage and get tree from working directory worktree, err := gitRepo.Worktree() if err != nil { return fmt.Errorf("failed to get worktree: %w", err) } // Get all files including untracked ones status, err := worktree.Status() if err != nil { return fmt.Errorf("failed to get status: %w", err) } // Add all files (including untracked) to the index for file := range status { _, err = worktree.Add(file) if err != nil { return fmt.Errorf("failed to add file %s: %w", file, err) } } if len(status) == 0 { return fmt.Errorf("no changes to commit") } // Create a temporary commit to get the tree hash // This is a workaround since we need the tree object tempCommitHash, err := worktree.Commit("temp for tree extraction", &gogit.CommitOptions{ All: true, AllowEmptyCommits: false, }) if err != nil { return fmt.Errorf("failed to create temporary commit: %w", err) } // Get the tree hash from the temporary commit tempCommit, err := gitRepo.CommitObject(tempCommitHash) if err != nil { return fmt.Errorf("failed to get temporary commit: %w", err) } treeHash = tempCommit.TreeHash.String() // Note: We leave the temp commit in place - it will be orphaned // when we create the real commit, and can be cleaned up by git gc } else { // 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) } treeHash = ephemeralCommit.TreeHash.String() } // 3. Create workstream manager wsManager := core.NewWorkstreamManager(repo) // 4. Get the current workstream currentWorkstream, err := wsManager.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 commit baseCommitSHA := currentWorkstream.Metadata["base_commit"] if baseCommitSHA == "" { // Fallback to getting the base branch HEAD baseBranch := currentWorkstream.BaseBranch if baseBranch == "" { baseBranch = "main" } branchRef := fmt.Sprintf("refs/heads/%s", baseBranch) sha, err := gitBackend.GetRef(branchRef) if err == nil { parentSHA = sha } } else { parentSHA = baseCommitSHA } } // 6. Create new commit with the user's message commitSHA, err := gitBackend.CreateCommit(treeHash, parentSHA, message, "User") if err != nil { return fmt.Errorf("failed to create commit: %w", err) } // 7. Add commit to workstream using the manager if err := wsManager.AddCommitToWorkstream(commitSHA, message); err != nil { return fmt.Errorf("failed to add commit to workstream: %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 } // 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 }