diff options
-rw-r--r-- | commands/msg/pipe.go | 16 | ||||
-rw-r--r-- | commands/patch/apply.go | 248 | ||||
-rw-r--r-- | commands/patch/apply_test.go | 53 | ||||
-rw-r--r-- | doc/aerc-patch.7.scd | 13 | ||||
-rw-r--r-- | lib/pama/apply.go | 137 |
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) +} |