aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorKoni Marti <koni.marti@gmail.com>2023-11-24 16:03:02 +0100
committerRobin Jarry <robin@jarry.cc>2023-12-30 15:42:09 +0100
commitfdd9f7991aa50bd99d21c178a2816fc075eead6b (patch)
tree737207d7dcb5b626f57d59e7b0b247d73a352f28
parentf98382d1dfc8970d3006fcc2175dd514bf7e07d0 (diff)
downloadaerc-fdd9f7991aa50bd99d21c178a2816fc075eead6b.tar.gz
patch/apply: add apply sub-cmd
Add the :patch apply command to apply a patch set and create a corresponding tag. The tag command argument can be completed based on the subject lines of the selected messages. Add a test for the completion proposal. Signed-off-by: Koni Marti <koni.marti@gmail.com> Acked-by: Robin Jarry <robin@jarry.cc>
-rw-r--r--commands/msg/pipe.go16
-rw-r--r--commands/patch/apply.go248
-rw-r--r--commands/patch/apply_test.go53
-rw-r--r--doc/aerc-patch.7.scd13
-rw-r--r--lib/pama/apply.go137
5 files changed, 464 insertions, 3 deletions
diff --git a/commands/msg/pipe.go b/commands/msg/pipe.go
index b32d7159..ee5cd965 100644
--- a/commands/msg/pipe.go
+++ b/commands/msg/pipe.go
@@ -33,6 +33,10 @@ func (Pipe) Aliases() []string {
}
func (p Pipe) Execute(args []string) error {
+ return p.Run(nil)
+}
+
+func (p Pipe) Run(cb func()) error {
if p.Full && p.Part {
return errors.New("-m and -p are mutually exclusive")
}
@@ -57,6 +61,15 @@ func (p Pipe) Execute(args []string) error {
app.PushError(err.Error())
return
}
+ if cb != nil {
+ last := term.OnClose
+ term.OnClose = func(err error) {
+ if last != nil {
+ last(err)
+ }
+ cb()
+ }
+ }
app.NewTab(term, name)
}
@@ -89,6 +102,9 @@ func (p Pipe) Execute(args []string) error {
ecmd.ProcessState.ExitCode()), 10*time.Second)
}
}
+ if cb != nil {
+ cb()
+ }
}
app.PushStatus("Fetching messages ...", 10*time.Second)
diff --git a/commands/patch/apply.go b/commands/patch/apply.go
new file mode 100644
index 00000000..f9c74ec3
--- /dev/null
+++ b/commands/patch/apply.go
@@ -0,0 +1,248 @@
+package patch
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "unicode"
+
+ "git.sr.ht/~rjarry/aerc/app"
+ "git.sr.ht/~rjarry/aerc/commands"
+ "git.sr.ht/~rjarry/aerc/commands/msg"
+ "git.sr.ht/~rjarry/aerc/lib/pama"
+ "git.sr.ht/~rjarry/aerc/lib/pama/models"
+ "git.sr.ht/~rjarry/aerc/log"
+)
+
+type Apply struct {
+ Cmd string `opt:"-c"`
+ Tag string `opt:"tag" required:"true" complete:"CompleteTag"`
+}
+
+func init() {
+ register(Apply{})
+}
+
+func (Apply) Aliases() []string {
+ return []string{"apply"}
+}
+
+func (*Apply) CompleteTag(arg string) []string {
+ patches, err := pama.New().CurrentPatches()
+ if err != nil {
+ log.Errorf("failed to current patches for completion: %v", err)
+ patches = nil
+ }
+
+ acct := app.SelectedAccount()
+ if acct == nil {
+ return nil
+ }
+
+ uids, err := acct.MarkedMessages()
+ if err != nil {
+ return nil
+ }
+ if len(uids) == 0 {
+ msg, err := acct.SelectedMessage()
+ if err == nil {
+ uids = append(uids, msg.Uid)
+ }
+ }
+
+ store := acct.Store()
+ if store == nil {
+ return nil
+ }
+
+ var subjects []string
+ for _, uid := range uids {
+ if msg, ok := store.Messages[uid]; !ok || msg == nil || msg.Envelope == nil {
+ continue
+ } else {
+ subjects = append(subjects, msg.Envelope.Subject)
+ }
+ }
+ return proposePatchName(patches, subjects)
+}
+
+func (a Apply) Execute(args []string) error {
+ patch := a.Tag
+
+ m := pama.New()
+ p, err := m.CurrentProject()
+ if err != nil {
+ return err
+ }
+ log.Tracef("Current project: %v", p)
+
+ if models.Commits(p.Commits).HasTag(patch) {
+ return fmt.Errorf("Patch name '%s' already exists.", patch)
+ }
+
+ if !m.Clean(p) {
+ return fmt.Errorf("Aborting... There are unstaged changes in " +
+ "your repository.")
+ }
+
+ commit, err := m.Head(p)
+ if err != nil {
+ return err
+ }
+ log.Tracef("HEAD commit before: %s", commit)
+
+ applyCmd, err := m.ApplyCmd(p)
+ if err != nil {
+ return err
+ }
+
+ if a.Cmd != "" {
+ applyCmd = a.Cmd
+ rootFmt := "%r"
+ if strings.Contains(applyCmd, rootFmt) {
+ applyCmd = strings.ReplaceAll(applyCmd, rootFmt, p.Root)
+ }
+ log.Infof("overwrite apply cmd by '%s'",
+ applyCmd)
+ }
+
+ msgData := collectMessageData()
+
+ // apply patches with the pipe cmd
+ pipe := msg.Pipe{
+ Background: false,
+ Full: true,
+ Part: false,
+ Command: applyCmd,
+ }
+ return pipe.Run(func() {
+ p, err = m.ApplyUpdate(p, patch, commit, msgData)
+ if err != nil {
+ log.Errorf("Failed to save patch data: %v", err)
+ }
+ })
+}
+
+// collectMessageData returns a map where the key is the message id and the
+// value the subject of the marked messages
+func collectMessageData() map[string]string {
+ acct := app.SelectedAccount()
+ if acct == nil {
+ return nil
+ }
+
+ uids, err := commands.MarkedOrSelected(acct)
+ if err != nil {
+ log.Errorf("error occurred: %v", err)
+ return nil
+ }
+
+ store := acct.Store()
+ if store == nil {
+ return nil
+ }
+
+ kv := make(map[string]string)
+ for _, uid := range uids {
+ msginfo, ok := store.Messages[uid]
+ if !ok || msginfo == nil {
+ continue
+ }
+ id, err := msginfo.MsgId()
+ if err != nil {
+ continue
+ }
+ if msginfo.Envelope == nil {
+ continue
+ }
+
+ kv[id] = msginfo.Envelope.Subject
+ }
+
+ return kv
+}
+
+func proposePatchName(patches, subjects []string) []string {
+ parse := func(s string) (string, string, bool) {
+ var tag strings.Builder
+ var version string
+ var i, j int
+
+ i = strings.Index(s, "[")
+ if i < 0 {
+ goto noPatch
+ }
+ s = s[i+1:]
+
+ j = strings.Index(s, "]")
+ if j < 0 {
+ goto noPatch
+ }
+ for _, elem := range strings.Fields(s[:j]) {
+ vers := strings.ToLower(elem)
+ if !strings.HasPrefix(vers, "v") {
+ continue
+ }
+ isVersion := true
+ for _, r := range vers[1:] {
+ if !unicode.IsDigit(r) {
+ isVersion = false
+ break
+ }
+ }
+ if isVersion {
+ version = vers
+ break
+ }
+ }
+ s = strings.TrimSpace(s[j+1:])
+
+ for _, r := range s {
+ if unicode.IsSpace(r) || r == ':' {
+ break
+ }
+ _, err := tag.WriteRune(r)
+ if err != nil {
+ continue
+ }
+ }
+ return tag.String(), version, true
+ noPatch:
+ return "", "", false
+ }
+
+ summary := make(map[string]struct{})
+
+ var results []string
+ for _, s := range subjects {
+ tag, version, isPatch := parse(s)
+ if tag == "" || !isPatch {
+ continue
+ }
+ if version == "" {
+ version = "v1"
+ }
+ result := fmt.Sprintf("%s_%s", tag, version)
+ result = strings.ReplaceAll(result, " ", "")
+
+ collision := false
+ for _, name := range patches {
+ if name == result {
+ collision = true
+ }
+ }
+ if collision {
+ continue
+ }
+
+ _, ok := summary[result]
+ if ok {
+ continue
+ }
+ results = append(results, result)
+ summary[result] = struct{}{}
+ }
+
+ sort.Strings(results)
+ return results
+}
diff --git a/commands/patch/apply_test.go b/commands/patch/apply_test.go
new file mode 100644
index 00000000..12a87c76
--- /dev/null
+++ b/commands/patch/apply_test.go
@@ -0,0 +1,53 @@
+package patch
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestPatchApply_ProposeName(t *testing.T) {
+ tests := []struct {
+ name string
+ exist []string
+ subjects []string
+ want []string
+ }{
+ {
+ name: "base case",
+ exist: nil,
+ subjects: []string{
+ "[PATCH aerc v3 3/3] notmuch: remove unused code",
+ "[PATCH aerc v3 2/3] notmuch: replace notmuch library with internal bindings",
+ "[PATCH aerc v3 1/3] notmuch: add notmuch bindings",
+ },
+ want: []string{"notmuch_v3"},
+ },
+ {
+ name: "distorted case",
+ exist: nil,
+ subjects: []string{
+ "[PATCH vaerc v3 3/3] notmuch: remove unused code",
+ "[PATCH aerc 3v 2/3] notmuch: replace notmuch library with internal bindings",
+ },
+ want: []string{"notmuch_v1", "notmuch_v3"},
+ },
+ {
+ name: "invalid patches",
+ exist: nil,
+ subjects: []string{
+ "notmuch: remove unused code",
+ ": replace notmuch library with internal bindings",
+ },
+ want: nil,
+ },
+ }
+
+ for _, test := range tests {
+ got := proposePatchName(test.exist, test.subjects)
+ if !reflect.DeepEqual(got, test.want) {
+ t.Errorf("test '%s' failed to propose the correct "+
+ "name: got '%v', but want '%v'", test.name,
+ got, test.want)
+ }
+ }
+}
diff --git a/doc/aerc-patch.7.scd b/doc/aerc-patch.7.scd
index 4209b493..98c271bb 100644
--- a/doc/aerc-patch.7.scd
+++ b/doc/aerc-patch.7.scd
@@ -35,13 +35,20 @@ The following *:patch* sub-commands are supported:
*-a*: Lists all projects.
-*:patch apply* _<tag>_
+*:patch apply* [*-c* _<cmd>_] _<tag>_
Applies the selected message(s) to the repository of the current
project. It uses the *:pipe* command for this and keeps track of the
applied patch.
- *aerc* will propose completions for _<tag>_ based on the subject lines
- of the selected or marked messages.
+ A user-defined command for applying patches can be used with the *-c*
+ option. Any occurence of '%r' in the command string will be replaced
+ with the root directory of the current project. However, this approach
+ is not recommended in general and should only be used for very specific
+ purposes, i.e. when a maintainer is applying a patch set via a separate
+ script to deal with git trailers.
+
+ *aerc* will propose completions for the _<tag>_ based on the subject
+ lines of the selected or marked messages.
*:patch remove* _<tag>_
Removes the patch _<tag>_ from the repository.
diff --git a/lib/pama/apply.go b/lib/pama/apply.go
new file mode 100644
index 00000000..15875818
--- /dev/null
+++ b/lib/pama/apply.go
@@ -0,0 +1,137 @@
+package pama
+
+import (
+ "encoding/base64"
+ "fmt"
+ "math/rand"
+ "strings"
+
+ "git.sr.ht/~rjarry/aerc/lib/pama/models"
+ "git.sr.ht/~rjarry/aerc/log"
+)
+
+func (m PatchManager) CurrentProject() (p models.Project, err error) {
+ store := m.store()
+ name, err := store.CurrentName()
+ if name == "" || err != nil {
+ log.Errorf("failed to get current name: %v", storeErr(err))
+ err = fmt.Errorf("No current project set. " +
+ "Run :patch init first")
+ return
+ }
+ names, err := store.Names()
+ if err != nil {
+ err = storeErr(err)
+ return
+ }
+ notFound := true
+ for _, s := range names {
+ if s == name {
+ notFound = !notFound
+ break
+ }
+ }
+ if notFound {
+ err = fmt.Errorf("Project '%s' does not exist anymore. "+
+ "Run :patch init or :patch switch", name)
+ return
+ }
+ p, err = store.Current()
+ if err != nil {
+ err = storeErr(err)
+ }
+ return
+}
+
+func (m PatchManager) CurrentPatches() ([]string, error) {
+ c, err := m.CurrentProject()
+ if err != nil {
+ return nil, err
+ }
+ return models.Commits(c.Commits).Tags(), nil
+}
+
+func (m PatchManager) Head(p models.Project) (string, error) {
+ rc, err := m.rc(p.RevctrlID, p.Root)
+ if err != nil {
+ return "", revErr(err)
+ }
+ return rc.Head()
+}
+
+func (m PatchManager) Clean(p models.Project) bool {
+ rc, err := m.rc(p.RevctrlID, p.Root)
+ if err != nil {
+ log.Errorf("could not get revctl: %v", revErr(err))
+ return false
+ }
+ return rc.Clean()
+}
+
+func (m PatchManager) ApplyCmd(p models.Project) (string, error) {
+ rc, err := m.rc(p.RevctrlID, p.Root)
+ if err != nil {
+ return "", revErr(err)
+ }
+ return rc.ApplyCmd(), nil
+}
+
+func generateTag(n int) (string, error) {
+ b := make([]byte, n)
+ _, err := rand.Read(b)
+ if err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+func makeUnique(s string) string {
+ tag, err := generateTag(4)
+ if err != nil {
+ return fmt.Sprintf("%s_%d", s, rand.Uint32())
+ }
+ return fmt.Sprintf("%s_%s", s, tag)
+}
+
+// ApplyUpdate is called after the commits have been applied with the
+// ApplyCmd(). It will determine the additional commits from the commitID (last
+// HEAD position), assign the patch tag to those commits and store them in
+// project p.
+func (m PatchManager) ApplyUpdate(p models.Project, patch, commitID string,
+ kv map[string]string,
+) (models.Project, error) {
+ rc, err := m.rc(p.RevctrlID, p.Root)
+ if err != nil {
+ return p, revErr(err)
+ }
+
+ commitIDs, err := rc.History(commitID)
+ if err != nil {
+ return p, revErr(err)
+ }
+ if len(commitIDs) == 0 {
+ return p, fmt.Errorf("no commits found for patch %s", patch)
+ }
+
+ if models.Commits(p.Commits).HasTag(patch) {
+ log.Warnf("Patch name '%s' already exists", patch)
+ patch = makeUnique(patch)
+ log.Warnf("Creating new name: '%s'", patch)
+ }
+
+ for _, c := range commitIDs {
+ nc := models.NewCommit(rc, c, patch)
+ for msgid, subj := range kv {
+ if nc.Subject == "" {
+ continue
+ }
+ if strings.Contains(subj, nc.Subject) {
+ nc.MessageId = msgid
+ }
+ }
+ p.Commits = append(p.Commits, nc)
+ }
+
+ err = m.store().StoreProject(p, true)
+ return p, storeErr(err)
+}