aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorKoni Marti <koni.marti@gmail.com>2023-11-24 16:03:06 +0100
committerRobin Jarry <robin@jarry.cc>2023-12-30 15:42:09 +0100
commit6592d6e711a2d909465a758259adc76edc5ae3e6 (patch)
tree2316f4e69cf1b005af010ea177e86f5c90a09198
parentc711fe1cab738fdc1e9c12c601d80d6bf5892c9e (diff)
downloadaerc-6592d6e711a2d909465a758259adc76edc5ae3e6.tar.gz
patch/rebase: add rebase sub-cmd
Implement the :patch rebase command. Rebase the internal patch data in case the repository was rebased too. The :patch rebase command accepts a optional argument which is used as the reference commit from which the rebasing will occur. Open an editor with the commits that are found on top of the rebase reference. Any untracked commit will have the tag 'untracked'. If a line is removed or the 'untracked' tag remains, it will be dropped from the internal data. To group commits into a patch set, assign the same tag names. Signed-off-by: Koni Marti <koni.marti@gmail.com> Acked-by: Robin Jarry <robin@jarry.cc>
-rw-r--r--commands/patch/rebase.go249
-rw-r--r--commands/patch/rebase_test.go114
-rw-r--r--lib/pama/rebase.go81
3 files changed, 444 insertions, 0 deletions
diff --git a/commands/patch/rebase.go b/commands/patch/rebase.go
new file mode 100644
index 00000000..10da2a63
--- /dev/null
+++ b/commands/patch/rebase.go
@@ -0,0 +1,249 @@
+package patch
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "sort"
+ "strings"
+ "time"
+
+ "git.sr.ht/~rjarry/aerc/app"
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib/pama"
+ "git.sr.ht/~rjarry/aerc/lib/pama/models"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+ "git.sr.ht/~rjarry/aerc/log"
+)
+
+type Rebase struct {
+ Commit string `opt:"commit" required:"false"`
+}
+
+func init() {
+ register(Rebase{})
+}
+
+func (Rebase) Aliases() []string {
+ return []string{"rebase"}
+}
+
+func (r Rebase) Execute(args []string) error {
+ m := pama.New()
+ current, err := m.CurrentProject()
+ if err != nil {
+ return err
+ }
+
+ baseID := r.Commit
+ if baseID == "" {
+ baseID = current.Base.ID
+ }
+
+ commits, err := m.RebaseCommits(current, baseID)
+ if err != nil {
+ return err
+ }
+
+ if len(commits) == 0 {
+ err := m.SaveRebased(current, baseID, nil)
+ if err != nil {
+ return fmt.Errorf("No commits to rebase, but saving of new reference failed: %w", err)
+ }
+ app.PushStatus("No commits to rebase.", 10*time.Second)
+ return nil
+ }
+
+ rebase := newRebase(commits)
+ f, err := os.CreateTemp("", "aerc-patch-rebase-*")
+ if err != nil {
+ return err
+ }
+ name := f.Name()
+ _, err = io.Copy(f, rebase.content())
+ if err != nil {
+ return err
+ }
+ f.Close()
+
+ createWidget := func() (ui.DrawableInteractive, error) {
+ editorCmd, err := app.CmdFallbackSearch(config.EditorCmds(), true)
+ if err != nil {
+ return nil, err
+ }
+ editor := exec.Command("/bin/sh", "-c", editorCmd+" "+name)
+ term, err := app.NewTerminal(editor)
+ if err != nil {
+ return nil, err
+ }
+ term.OnClose = func(_ error) {
+ app.CloseDialog()
+ defer os.Remove(name)
+ defer term.Focus(false)
+
+ f, err := os.Open(name)
+ if err != nil {
+ app.PushError(fmt.Sprintf("failed to open file: %v", err))
+ return
+ }
+ defer f.Close()
+
+ if editor.ProcessState.ExitCode() > 0 {
+ app.PushError("Quitting rebase without saving.")
+ return
+ }
+ err = m.SaveRebased(current, baseID, rebase.parse(f))
+ if err != nil {
+ app.PushError(fmt.Sprintf("Failed to save rebased commits: %v", err))
+ return
+ }
+ app.PushStatus("Successfully rebased.", 10*time.Second)
+ }
+ term.Show(true)
+ term.Focus(true)
+ return term, nil
+ }
+
+ viewer, err := createWidget()
+ if err != nil {
+ return err
+ }
+
+ app.AddDialog(app.NewDialog(
+ ui.NewBox(viewer, fmt.Sprintf("Patch Rebase on %-6.6s", baseID), "",
+ app.SelectedAccountUiConfig(),
+ ),
+ // start pos on screen
+ func(h int) int {
+ return h / 8
+ },
+ // dialog height
+ func(h int) int {
+ return h - 2*h/8
+ },
+ ))
+
+ return nil
+}
+
+type rebase struct {
+ commits []models.Commit
+ table map[string]models.Commit
+ order []string
+}
+
+func newRebase(commits []models.Commit) *rebase {
+ return &rebase{
+ commits: commits,
+ table: make(map[string]models.Commit),
+ }
+}
+
+const (
+ header string = ""
+ footer string = `
+# Rebase aerc's patch data. This will not affect the underlying repository in
+# any way.
+#
+# Change the name in the first column to assign a new tag to a commit. To group
+# multiple commits, use the same tag name.
+#
+# An 'untracked' tag indicates that aerc lost track of that commit, either due
+# to a commit-hash change or because that commit was applied outside of aerc.
+#
+# Do not change anything else besides the tag names (first column).
+#
+# Do not reorder the lines. The ordering should remain as in the repository.
+#
+# If you remove a line or keep an 'untracked' tag, those commits will be removed
+# from aerc's patch tracking.
+#
+`
+)
+
+func (r *rebase) content() io.Reader {
+ var buf bytes.Buffer
+ buf.WriteString(header)
+ for _, c := range r.commits {
+ tag := c.Tag
+ if tag == "" {
+ tag = models.Untracked
+ }
+ shortHash := fmt.Sprintf("%6.6s", c.ID)
+ buf.WriteString(
+ fmt.Sprintf("%-12s %6.6s %s\n", tag, shortHash, c.Info()))
+ r.table[shortHash] = c
+ r.order = append(r.order, shortHash)
+ }
+ buf.WriteString(footer)
+ return &buf
+}
+
+func (r *rebase) parse(reader io.Reader) []models.Commit {
+ var commits []models.Commit
+ var hashes []string
+ scanner := bufio.NewScanner(reader)
+ duplicated := make(map[string]struct{})
+ for scanner.Scan() {
+ s := scanner.Text()
+ i := strings.Index(s, "#")
+ if i >= 0 {
+ s = s[:i]
+ }
+ if strings.TrimSpace(s) == "" {
+ continue
+ }
+
+ fds := strings.Fields(s)
+ if len(fds) < 2 {
+ continue
+ }
+
+ tag, shortHash := fds[0], fds[1]
+ if tag == models.Untracked {
+ continue
+ }
+ _, dedup := duplicated[shortHash]
+ if dedup {
+ log.Warnf("rebase: skipping duplicated hash: %s", shortHash)
+ continue
+ }
+
+ hashes = append(hashes, shortHash)
+ c, ok := r.table[shortHash]
+ if !ok {
+ log.Errorf("Looks like the commit hashes were changed "+
+ "during the rebase. Dropping: %v", shortHash)
+ continue
+ }
+ log.Tracef("save commit %s with tag %s", shortHash, tag)
+ c.Tag = tag
+ commits = append(commits, c)
+ duplicated[shortHash] = struct{}{}
+ }
+ reorder(commits, hashes, r.order)
+ return commits
+}
+
+func reorder(toSort []models.Commit, now []string, by []string) {
+ byMap := make(map[string]int)
+ for i, s := range by {
+ byMap[s] = i
+ }
+
+ complete := true
+ for _, s := range now {
+ _, ok := byMap[s]
+ complete = complete && ok
+ }
+ if !complete {
+ return
+ }
+
+ sort.SliceStable(toSort, func(i, j int) bool {
+ return byMap[now[i]] < byMap[now[j]]
+ })
+}
diff --git a/commands/patch/rebase_test.go b/commands/patch/rebase_test.go
new file mode 100644
index 00000000..fd3d705b
--- /dev/null
+++ b/commands/patch/rebase_test.go
@@ -0,0 +1,114 @@
+package patch
+
+import (
+ "fmt"
+ "reflect"
+ "strings"
+ "testing"
+
+ "git.sr.ht/~rjarry/aerc/lib/pama/models"
+)
+
+func TestRebase_reorder(t *testing.T) {
+ newCommits := func(order []string) []models.Commit {
+ var commits []models.Commit
+ for _, s := range order {
+ commits = append(commits, models.Commit{ID: s})
+ }
+ return commits
+ }
+ tests := []struct {
+ name string
+ commits []models.Commit
+ now []string
+ by []string
+ want []models.Commit
+ }{
+ {
+ name: "nothing to reorder",
+ commits: newCommits([]string{"1", "2", "3"}),
+ now: []string{"1", "2", "3"},
+ by: []string{"1", "2", "3"},
+ want: newCommits([]string{"1", "2", "3"}),
+ },
+ {
+ name: "reorder",
+ commits: newCommits([]string{"1", "3", "2"}),
+ now: []string{"1", "3", "2"},
+ by: []string{"1", "2", "3"},
+ want: newCommits([]string{"1", "2", "3"}),
+ },
+ {
+ name: "reorder inverted",
+ commits: newCommits([]string{"3", "2", "1"}),
+ now: []string{"3", "2", "1"},
+ by: []string{"1", "2", "3"},
+ want: newCommits([]string{"1", "2", "3"}),
+ },
+ {
+ name: "changed hash: do not sort",
+ commits: newCommits([]string{"1", "6", "3"}),
+ now: []string{"1", "6", "3"},
+ by: []string{"1", "2", "3"},
+ want: newCommits([]string{"1", "6", "3"}),
+ },
+ }
+
+ for _, test := range tests {
+ reorder(test.commits, test.now, test.by)
+ if !reflect.DeepEqual(test.commits, test.want) {
+ t.Errorf("test '%s' failed to reorder: got %v but "+
+ "want %v", test.name, test.commits, test.want)
+ }
+ }
+}
+
+func newCommit(id, subj, tag string) models.Commit {
+ return models.Commit{
+ ID: id,
+ Subject: subj,
+ Tag: tag,
+ }
+}
+
+func TestRebase_parse(t *testing.T) {
+ input := `
+ # some header info
+ hello_v1 123 same info
+ hello_v1 456 same info
+ untracked 789 same info
+ hello_v2 012 diff info
+ untracked 345 diff info # not very useful comment
+ # some footer info
+ `
+ commits := []models.Commit{
+ newCommit("123123", "same info", "hello_v1"),
+ newCommit("456456", "same info", "hello_v1"),
+ newCommit("789789", "same info", models.Untracked),
+ newCommit("012012", "diff info", "hello_v2"),
+ newCommit("345345", "diff info", models.Untracked),
+ }
+
+ var order []string
+ for _, c := range commits {
+ order = append(order, fmt.Sprintf("%3.3s", c.ID))
+ }
+
+ table := make(map[string]models.Commit)
+ for i, shortId := range order {
+ table[shortId] = commits[i]
+ }
+
+ rebase := &rebase{
+ commits: commits,
+ table: table,
+ order: order,
+ }
+
+ results := rebase.parse(strings.NewReader(input))
+
+ if len(results) != 3 {
+ t.Errorf("failed to return correct number of commits: "+
+ "got %d but wanted 3", len(results))
+ }
+}
diff --git a/lib/pama/rebase.go b/lib/pama/rebase.go
new file mode 100644
index 00000000..8f316b7e
--- /dev/null
+++ b/lib/pama/rebase.go
@@ -0,0 +1,81 @@
+package pama
+
+import (
+ "fmt"
+
+ "git.sr.ht/~rjarry/aerc/lib/pama/models"
+)
+
+// RebaseCommits fetches the commits between baseID and HEAD. The tags from the
+// current project will be mapped onto the fetched commits based on either the
+// commit hash or the commit subject.
+func (m PatchManager) RebaseCommits(p models.Project, baseID string) ([]models.Commit, error) {
+ rc, err := m.rc(p.RevctrlID, p.Root)
+ if err != nil {
+ return nil, revErr(err)
+ }
+
+ if !rc.Exists(baseID) {
+ return nil, fmt.Errorf("cannot rebase on %s. "+
+ "commit does not exist", baseID)
+ }
+
+ commitIDs, err := rc.History(baseID)
+ if err != nil {
+ return nil, err
+ }
+
+ commits := make([]models.Commit, len(commitIDs))
+ for i := 0; i < len(commitIDs); i++ {
+ commits[i] = models.NewCommit(
+ rc,
+ commitIDs[i],
+ models.Untracked,
+ )
+ }
+
+ // map tags from the commits from the project p
+ for i, r := range commits {
+ for _, c := range p.Commits {
+ if c.ID == r.ID || c.Subject == r.Subject {
+ commits[i].MessageId = c.MessageId
+ commits[i].Tag = c.Tag
+ break
+ }
+ }
+ }
+
+ return commits, nil
+}
+
+// SaveRebased checks if the commits actually exist in the repo, repopulate the
+// info fields and saves the baseID for project p.
+func (m PatchManager) SaveRebased(p models.Project, baseID string, commits []models.Commit) error {
+ rc, err := m.rc(p.RevctrlID, p.Root)
+ if err != nil {
+ return revErr(err)
+ }
+
+ exist := make([]models.Commit, 0, len(commits))
+ for _, c := range commits {
+ if !rc.Exists(c.ID) {
+ continue
+ }
+ exist = append(exist, c)
+ }
+
+ for i, c := range exist {
+ exist[i].Subject = rc.Subject(c.ID)
+ exist[i].Author = rc.Author(c.ID)
+ exist[i].Date = rc.Date(c.ID)
+ }
+
+ p.Commits = exist
+
+ if rc.Exists(baseID) {
+ p.Base = models.NewCommit(rc, baseID, "")
+ }
+
+ err = m.store().StoreProject(p, true)
+ return storeErr(err)
+}