arche / internal/cli/cmd_split.go

commit 154431fd
  1package cli
  2
  3import (
  4	"bufio"
  5	"fmt"
  6	"os"
  7	"strings"
  8	"time"
  9
 10	"arche/internal/object"
 11	"arche/internal/repo"
 12	"arche/internal/store"
 13	"arche/internal/tui"
 14	"arche/internal/wc"
 15
 16	"github.com/spf13/cobra"
 17)
 18
 19var splitCmd = &cobra.Command{
 20	Use:   "split",
 21	Short: "Split the current working-copy changes into two commits using interactive hunk selection",
 22	Long: `Interactively select which hunks belong to the first commit; the remaining
 23changes become a second draft commit chained on top.`,
 24	RunE: func(cmd *cobra.Command, args []string) error {
 25		r := openRepo()
 26		defer r.Close()
 27
 28		w := wc.New(r)
 29
 30		head, headID, err := r.HeadCommit()
 31		if err != nil {
 32			return err
 33		}
 34
 35		before, _ := r.CaptureRefState()
 36
 37		diffs, err := w.ComputeWorkingDiffs()
 38		if err != nil {
 39			return err
 40		}
 41		if len(diffs) == 0 {
 42			fmt.Println("Nothing to split: working copy has no changes.")
 43			return nil
 44		}
 45
 46		var items []tui.HunkItem
 47		for _, fhd := range diffs {
 48			for hi, h := range fhd.Hunks {
 49				items = append(items, tui.HunkItem{
 50					FilePath:         fhd.Path,
 51					HunkIdx:          hi,
 52					TotalHunksInFile: len(fhd.Hunks),
 53					Hunk:             h,
 54				})
 55			}
 56		}
 57
 58		sel, err := tui.RunHunkSelector(items, "include in first commit")
 59		if err != nil {
 60			return err
 61		}
 62		if sel.Cancelled {
 63			fmt.Fprintln(os.Stderr, "Split cancelled.")
 64			return nil
 65		}
 66
 67		perFile := make(map[string][]bool)
 68		idx := 0
 69		for _, fhd := range diffs {
 70			n := len(fhd.Hunks)
 71			perFile[fhd.Path] = sel.Selected[idx : idx+n]
 72			idx += n
 73		}
 74
 75		anyFirst := false
 76		for _, bs := range perFile {
 77			for _, b := range bs {
 78				if b {
 79					anyFirst = true
 80					break
 81				}
 82			}
 83		}
 84		if !anyFirst {
 85			return fmt.Errorf("no hunks selected for first commit – split aborted")
 86		}
 87
 88		sc := bufio.NewScanner(os.Stdin)
 89
 90		msg1 := strings.TrimSpace(head.Message)
 91		if msg1 == "" {
 92			msg1 = "change (1/2)"
 93		} else {
 94			msg1 += " (1/2)"
 95		}
 96		fmt.Printf("Message for first commit [%s]: ", msg1)
 97		sc.Scan()
 98		if t := strings.TrimSpace(sc.Text()); t != "" {
 99			msg1 = t
100		}
101
102		msg2 := strings.TrimSpace(head.Message)
103		if msg2 == "" {
104			msg2 = "change (2/2)"
105		} else {
106			msg2 += " (2/2)"
107		}
108		fmt.Printf("Message for second commit [%s]: ", msg2)
109		sc.Scan()
110		if t := strings.TrimSpace(sc.Text()); t != "" {
111			msg2 = t
112		}
113
114		c1, c1ID, err := w.SnapFirstOfSplit(msg1, diffs, perFile)
115		if err != nil {
116			return fmt.Errorf("snap first commit: %w", err)
117		}
118
119		c2, c2ID, err := w.SnapRemaining(msg2, c1ID)
120		if err != nil {
121			return fmt.Errorf("snap second commit: %w", err)
122		}
123
124		now := time.Now()
125		tx, err := r.Store.Begin()
126		if err != nil {
127			return err
128		}
129		obs := &object.ObsoleteMarker{
130			Predecessor: headID,
131			Successors:  [][32]byte{c1ID, c2ID},
132			Reason:      "split",
133		}
134		if _, err := repo.WriteObsoleteTx(r.Store, tx, obs); err != nil {
135			r.Store.Rollback(tx)
136			return err
137		}
138		after := fmt.Sprintf(`{"head":%q,"tip1":%q,"tip2":%q}`,
139			object.FormatChangeID(c2.ChangeID),
140			fmt.Sprintf("%x", c1ID),
141			fmt.Sprintf("%x", c2ID),
142		)
143		op := store.Operation{
144			Kind: "split", Timestamp: now.Unix(), Before: before, After: after,
145			Metadata: "split into 2 commits",
146		}
147		if _, err := r.Store.InsertOperation(tx, op); err != nil {
148			r.Store.Rollback(tx)
149			return err
150		}
151		if err := r.Store.Commit(tx); err != nil {
152			return err
153		}
154
155		fmt.Printf("Split into:\n  ch:%s  %s\n  ch:%s  %s\n",
156			c1.ChangeID, c1.Message,
157			c2.ChangeID, c2.Message,
158		)
159		return nil
160	},
161}