init: pristine aerc 0.20.0 source

This commit is contained in:
Mortdecai
2026-04-07 19:54:54 -04:00
commit 083402a548
502 changed files with 68722 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
package pama
import (
"crypto/rand"
"encoding/base64"
"fmt"
mathrand "math/rand"
"strings"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
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, mathrand.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)
}
+94
View File
@@ -0,0 +1,94 @@
package pama
import (
"fmt"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
func (m PatchManager) DropPatch(patch string) error {
p, err := m.CurrentProject()
if err != nil {
return err
}
if !models.Commits(p.Commits).HasTag(patch) {
return fmt.Errorf("Patch '%s' not found in project '%s'", patch, p.Name)
}
rc, err := m.rc(p.RevctrlID, p.Root)
if err != nil {
return revErr(err)
}
if !rc.Clean() {
return fmt.Errorf("Aborting... There are unstaged changes " +
"or a rebase in progress")
}
toRemove := make([]models.Commit, 0)
for _, c := range p.Commits {
if !rc.Exists(c.ID) {
log.Errorf("failed to find commit. %v", c)
return fmt.Errorf("Cannot drop patch. " +
"Please rebase first with ':patch rebase'")
}
if c.Tag == patch {
toRemove = append(toRemove, c)
}
}
removed := make(map[string]struct{})
for i := len(toRemove) - 1; i >= 0; i-- {
commitID := toRemove[i].ID
beforeIDs, err := rc.History(commitID)
if err != nil {
log.Errorf("failed to drop %v (commits before): %v", toRemove[i], err)
continue
}
err = rc.Drop(commitID)
if err != nil {
log.Errorf("failed to drop %v: %v", toRemove[i], err)
continue
}
removed[commitID] = struct{}{}
afterIDs, err := rc.History(p.Base.ID)
if err != nil {
log.Errorf("failed to drop %v (commits after): %v", toRemove[i], err)
continue
}
afterIDs = afterIDs[len(afterIDs)-len(beforeIDs):]
transform := make(map[string]string)
for j := 0; j < len(beforeIDs); j++ {
transform[beforeIDs[j]] = afterIDs[j]
}
for j, c := range p.Commits {
if newId, ok := transform[c.ID]; ok {
msgid := p.Commits[j].MessageId
p.Commits[j] = models.NewCommit(
rc,
newId,
p.Commits[j].Tag,
)
p.Commits[j].MessageId = msgid
}
}
}
if len(removed) < len(toRemove) {
return fmt.Errorf("Failed to drop commits. Dropped %d of %d.",
len(removed), len(toRemove))
}
commits := make([]models.Commit, 0, len(p.Commits))
for _, c := range p.Commits {
if _, ok := removed[c.ID]; ok {
continue
}
commits = append(commits, c)
}
p.Commits = commits
return storeErr(m.store().StoreProject(p, true))
}
+85
View File
@@ -0,0 +1,85 @@
package pama_test
import (
"reflect"
"testing"
"git.sr.ht/~rjarry/aerc/lib/pama"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
func TestPatchmgmt_Drop(t *testing.T) {
setup := func(p models.Project) (pama.PatchManager, models.RevisionController, models.PersistentStorer) {
return newTestManager(
[]string{"0", "1", "2", "3", "4", "5"},
[]string{"0", "a", "b", "c", "d", "f"},
map[string]models.Project{p.Name: p}, p.Name,
)
}
tests := []struct {
name string
drop string
commits []models.Commit
want []models.Commit
}{
{
name: "drop only patch",
drop: "patch1",
commits: []models.Commit{
newCommit("1", "a", "patch1"),
},
want: []models.Commit{},
},
{
name: "drop second one of two patch",
drop: "patch2",
commits: []models.Commit{
newCommit("1", "a", "patch1"),
newCommit("2", "b", "patch2"),
},
want: []models.Commit{
newCommit("1", "a", "patch1"),
},
},
{
name: "drop first one of two patch",
drop: "patch1",
commits: []models.Commit{
newCommit("1", "a", "patch1"),
newCommit("2", "b", "patch2"),
},
want: []models.Commit{
newCommit("2_new", "b", "patch2"),
},
},
}
for _, test := range tests {
p := models.Project{
Name: "project1",
Commits: test.commits,
Base: newCommit("0", "0", ""),
}
mgr, rc, _ := setup(p)
err := mgr.DropPatch(test.drop)
if err != nil {
t.Errorf("test '%s' failed. %v", test.name, err)
}
q, _ := mgr.CurrentProject()
if !reflect.DeepEqual(q.Commits, test.want) {
t.Errorf("test '%s' failed. Commits don't match: "+
"got %v, but wanted %v", test.name, q.Commits,
test.want)
}
if len(test.want) > 0 {
last := test.want[len(test.want)-1]
if !rc.Exists(last.ID) {
t.Errorf("test '%s' failed. Could not find last commits: %v", test.name, last)
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
package pama
import (
"fmt"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
func (m PatchManager) Find(hash string, p models.Project) (models.Commit, error) {
var c models.Commit
rc, err := m.rc(p.RevctrlID, p.Root)
if err != nil {
return c, revErr(err)
}
if !rc.Exists(hash) {
return c, fmt.Errorf("no commit found for hash %s", hash)
}
return models.NewCommit(rc, hash, ""), nil
}
+34
View File
@@ -0,0 +1,34 @@
package pama
import (
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
// Init creates a new revision control project
func (m PatchManager) Init(name, path string, overwrite bool) error {
id, root, err := m.detect(path)
if err != nil {
return err
}
rc, err := m.rc(id, root)
if err != nil {
return err
}
headID, err := rc.Head()
if err != nil {
return err
}
p := models.Project{
Name: name,
Root: root,
RevctrlID: id,
Base: models.NewCommit(rc, headID, ""),
Commits: make([]models.Commit, 0),
}
store := m.store()
err = store.StoreProject(p, overwrite)
if err != nil {
return storeErr(err)
}
return storeErr(store.SetCurrent(name))
}
+59
View File
@@ -0,0 +1,59 @@
package pama
import (
"errors"
"io"
"strings"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
func (m PatchManager) Projects(name string) ([]models.Project, error) {
all, err := m.store().Projects()
if err != nil {
return nil, storeErr(err)
}
if len(name) == 0 {
return all, nil
}
var projects []models.Project
for _, p := range all {
if strings.Contains(p.Name, name) {
projects = append(projects, p)
}
}
if len(projects) == 0 {
return nil, errors.New("No projects found.")
}
return projects, nil
}
func (m PatchManager) NewReader(projects []models.Project) io.Reader {
cur, err := m.CurrentProject()
currentName := cur.Name
if err != nil {
log.Warnf("could not get current project: %v", err)
currentName = ""
}
readers := make([]io.Reader, 0, len(projects))
for _, p := range projects {
rc, err := m.rc(p.RevctrlID, p.Root)
if err != nil {
log.Errorf("project '%s' failed with: %v", p.Name, err)
continue
}
notes := make(map[string]string)
for _, c := range p.Commits {
if !rc.Exists(c.ID) {
notes[c.ID] = "Rebase needed"
}
}
active := p.Name == currentName && len(projects) > 1
readers = append(readers, p.NewReader(active, notes))
}
return io.MultiReader(readers...)
}
+93
View File
@@ -0,0 +1,93 @@
package models
import (
"fmt"
"strings"
)
const (
Untracked = "untracked"
)
func NewCommit(r RevisionController, id, tag string) Commit {
return Commit{
ID: id,
Subject: r.Subject(id),
Author: r.Author(id),
Date: r.Date(id),
MessageId: "",
Tag: tag,
}
}
func (c Commit) Untracked() bool {
return c.Tag == Untracked
}
func (c Commit) Info() string {
s := []string{}
if c.Subject == "" {
s = append(s, "(no subject)")
} else {
s = append(s, c.Subject)
}
if c.Author != "" {
s = append(s, c.Author)
}
if c.Date != "" {
s = append(s, c.Date)
}
if c.MessageId != "" {
s = append(s, "<"+c.MessageId+">")
}
return strings.Join(s, ", ")
}
func (c Commit) String() string {
return fmt.Sprintf("%-6.6s %s", c.ID, c.Info())
}
type Commits []Commit
func (h Commits) Tags() []string {
var tags []string
dedup := make(map[string]struct{})
for _, c := range h {
_, ok := dedup[c.Tag]
if ok {
continue
}
tags = append(tags, c.Tag)
dedup[c.Tag] = struct{}{}
}
return tags
}
func (h Commits) HasTag(t string) bool {
for _, c := range h {
if c.Tag == t {
return true
}
}
return false
}
func (h Commits) Lookup(id string) (Commit, bool) {
for _, c := range h {
if c.ID == id {
return c, true
}
}
return Commit{}, false
}
type CommitIDs []string
func (c CommitIDs) Has(id string) bool {
for _, cid := range c {
if cid == id {
return true
}
}
return false
}
+106
View File
@@ -0,0 +1,106 @@
package models
// Commit represents a commit object in a revision control system.
type Commit struct {
// ID is the commit hash.
ID string
// Subject is the subject line of the commit.
Subject string
// Author is the author's name.
Author string
// Date associated with the given commit.
Date string
// MessageId is the message id for the message that contains the commit
// diff. This field is only set when commits were applied via patch
// apply system.
MessageId string
// Tag is a user label that is assigned to one or multiple commits. It
// creates a logical connection between a group of commits to represent
// a patch set.
Tag string
}
// WorktreeParent stores the name and repo location for the base project in the
// linked worktree project.
type WorktreeParent struct {
// Name is the project name from the base repo.
Name string
// Root is the root directory of the base repo.
Root string
}
// Project contains the data to access a revision control system and to store
// the internal patch tracking data.
type Project struct {
// Name is the project name and works as the project ID. Do not change
// it.
Name string
// Root represents the root directory of the revision control system.
Root string
// RevctrlID stores the ID for the revision control system.
RevctrlID string
// Worktree keeps the base repo information. If Worktree.Name and
// Worktree.Root are not zero, this project contains a linked worktree.
Worktree WorktreeParent
// Base represents the reference (base) commit.
Base Commit
// Commits contains the commits that are being tracked. The slice can
// contain any commit between the Base commit and HEAD. These commits
// will be updated by an applying, removing or rebase operation.
Commits []Commit
}
// RevisionController is an interface to a revision control system.
type RevisionController interface {
// Returns the commit hash of the HEAD commit.
Head() (string, error)
// History accepts a commit hash and returns a list of commit hashes
// between the provided hash and HEAD. The order of the returned slice
// is important. The commit hashes should be ordered from "earlier" to
// "later" where the last element must be HEAD.
History(string) ([]string, error)
// Clean returns true if there are no unstaged changes. If there are
// unstaged changes, applying and removing patches will not work.
Clean() bool
// Exists returns true if the commit hash exists in the commit history.
Exists(string) bool
// Subject returns the subject line for the provided commit hash.
Subject(string) string
// Author returns the author for the provided commit hash.
Author(string) string
// Date returns the date for the provided commit hash.
Date(string) string
// Drop removes the commit with the provided commit hash from the
// repository.
Drop(string) error
// ApplyCmd returns a string with an executable command that is used to
// apply patches with the :pipe command.
ApplyCmd() string
// CreateWorktree creates a worktree in path at commit.
CreateWorktree(path string, commit string) error
// DeleteWorktree removes the linked worktree stored in the path
// location. Note that this function should be called from the base
// repo.
DeleteWorktree(path string) error
}
// PersistentStorer is an interface to a persistent storage for Project structs.
type PersistentStorer interface {
// StoreProject saves the project data persistently. If overwrite is
// true, it will write over existing data.
StoreProject(Project, bool) error
// DeleteProject removes the project data from the store.
DeleteProject(string) error
// CurrentName returns the Project.Name for the active project.
CurrentName() (string, error)
// SetCurrent stores a Project.Name and make that project active.
SetCurrent(string) error
// Current returns the project data for the active project.
Current() (Project, error)
// Names returns a slice of Project.Name for all stored projects.
Names() ([]string, error)
// Project returns the stored project for the provided name.
Project(string) (Project, error)
// Projects returns all stored projects.
Projects() ([]Project, error)
}
+85
View File
@@ -0,0 +1,85 @@
package models
import (
"bytes"
"io"
"strings"
"text/template"
"git.sr.ht/~rjarry/aerc/lib/log"
)
var templateText = `
Project {{.Name}} {{if .IsActive}}[active]{{end}} {{if .IsWorktree}}[Linked worktree to {{.WorktreeParent}}]{{end}}
Directory {{.Root}}
Base {{with .Base.ID}}{{if ge (len .) 40}}{{printf "%-6.6s" .}}{{else}}{{.}}{{end}}{{end}}
{{$notes := .Notes}}{{$commits := .Commits}}
{{- range $index, $patch := .Patches}}
{{$patch}}:
{{- range (index $commits $patch)}}
{{with (index $notes .ID)}}[{{.}}] {{end}}{{. -}}
{{end}}
{{end -}}
`
var viewRenderer = template.Must(template.New("ProjectToText").Parse(templateText))
type view struct {
Name string
Root string
Base Commit
// Patches are the unique tag names.
Patches []string
// Commits is a map where the tag names are keys and the associated
// commits the values.
Commits map[string][]Commit
// Notes contain annotations of the commits where the commit hash is
// the key and the annotation is the value.
Notes map[string]string
// IsActive is true if the current project is selected.
IsActive bool
IsWorktree bool
WorktreeParent string
}
func newView(p Project, active bool, notes map[string]string) view {
v := view{
Name: p.Name,
Root: p.Root,
Base: p.Base,
Commits: make(map[string][]Commit),
Notes: notes,
IsActive: active,
IsWorktree: p.Worktree.Root != "" && p.Worktree.Name != "",
WorktreeParent: p.Worktree.Name,
}
for _, commit := range p.Commits {
patch := commit.Tag
commits, ok := v.Commits[patch]
if !ok {
v.Patches = append(v.Patches, patch)
}
commits = append(commits, commit)
v.Commits[patch] = commits
}
return v
}
func (v view) String() string {
var buf bytes.Buffer
err := viewRenderer.Execute(&buf, v)
if err != nil {
log.Errorf("failed to run template: %v", err)
}
return buf.String()
}
func (p Project) String() string {
return newView(p, false, nil).String()
}
func (p Project) NewReader(isActive bool, notes map[string]string) io.Reader {
return strings.NewReader(newView(p, isActive, notes).String())
}
+51
View File
@@ -0,0 +1,51 @@
package pama
import (
"fmt"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
"git.sr.ht/~rjarry/aerc/lib/pama/revctrl"
"git.sr.ht/~rjarry/aerc/lib/pama/store"
)
type (
detectFn func(string) (string, string, error)
rcFn func(string, string) (models.RevisionController, error)
storeFn func() models.PersistentStorer
)
type PatchManager struct {
detect detectFn
rc rcFn
store storeFn
}
func New() PatchManager {
return PatchManager{
detect: revctrl.Detect,
rc: revctrl.New,
store: store.Store,
}
}
func FromFunc(d detectFn, r rcFn, s storeFn) PatchManager {
return PatchManager{
detect: d,
rc: r,
store: s,
}
}
func storeErr(err error) error {
if err == nil {
return nil
}
return fmt.Errorf("store error: %w", err)
}
func revErr(err error) error {
if err == nil {
return nil
}
return fmt.Errorf("revision control error: %w", err)
}
+178
View File
@@ -0,0 +1,178 @@
package pama_test
import (
"errors"
"git.sr.ht/~rjarry/aerc/lib/pama"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
var errNotFound = errors.New("not found")
func newCommit(id, subj, tag string) models.Commit {
return models.Commit{ID: id, Subject: subj, Tag: tag}
}
func newTestManager(
commits []string,
subjects []string,
data map[string]models.Project,
current string,
) (pama.PatchManager, models.RevisionController, models.PersistentStorer) {
rc := mockRevctrl{
commitIDs: commits,
titles: subjects,
}
store := mockStore{
data: data,
current: current,
}
return pama.FromFunc(
nil,
func(_ string, _ string) (models.RevisionController, error) {
return &rc, nil
},
func() models.PersistentStorer {
return &store
},
), &rc, &store
}
type mockRevctrl struct {
commitIDs []string
titles []string
}
func (c *mockRevctrl) Support() bool {
return true
}
func (c *mockRevctrl) Clean() bool {
return true
}
func (c *mockRevctrl) Root() (string, error) {
return "", nil
}
func (c *mockRevctrl) Head() (string, error) {
return c.commitIDs[len(c.commitIDs)-1], nil
}
func (c *mockRevctrl) History(commit string) ([]string, error) {
for i, s := range c.commitIDs {
if s == commit {
cp := make([]string, len(c.commitIDs[i+1:]))
copy(cp, c.commitIDs[i+1:])
return cp, nil
}
}
return nil, errNotFound
}
func (c *mockRevctrl) Exists(commit string) bool {
for _, s := range c.commitIDs {
if s == commit {
return true
}
}
return false
}
func (c *mockRevctrl) Subject(commit string) string {
for i, s := range c.commitIDs {
if s == commit {
return c.titles[i]
}
}
return ""
}
func (c *mockRevctrl) Author(commit string) string {
return ""
}
func (c *mockRevctrl) Date(commit string) string {
return ""
}
func (c *mockRevctrl) Drop(commit string) error {
for i, s := range c.commitIDs {
if s == commit {
c.commitIDs = append(c.commitIDs[:i], c.commitIDs[i+1:]...)
c.titles = append(c.titles[:i], c.titles[i+1:]...)
// modify commitIDs to simulate a "real" change in
// commit history that will also change all subsequent
// commitIDs
for j := i; j < len(c.commitIDs); j++ {
c.commitIDs[j] += "_new"
}
return nil
}
}
return errNotFound
}
func (c *mockRevctrl) CreateWorktree(_, _ string) error {
return nil
}
func (c *mockRevctrl) DeleteWorktree(_ string) error {
return nil
}
func (c *mockRevctrl) ApplyCmd() string {
return ""
}
type mockStore struct {
data map[string]models.Project
current string
}
func (s *mockStore) StoreProject(p models.Project, ow bool) error {
_, ok := s.data[p.Name]
if ok && !ow {
return errors.New("already there")
}
s.data[p.Name] = p
return nil
}
func (s *mockStore) DeleteProject(name string) error {
delete(s.data, name)
return nil
}
func (s *mockStore) CurrentName() (string, error) {
return s.current, nil
}
func (s *mockStore) SetCurrent(c string) error {
s.current = c
return nil
}
func (s *mockStore) Current() (models.Project, error) {
return s.data[s.current], nil
}
func (s *mockStore) Names() ([]string, error) {
var names []string
for name := range s.data {
names = append(names, name)
}
return names, nil
}
func (s *mockStore) Project(_ string) (models.Project, error) {
return models.Project{}, nil
}
func (s *mockStore) Projects() ([]models.Project, error) {
var ps []models.Project
for _, p := range s.data {
ps = append(ps, p)
}
return ps, nil
}
+81
View File
@@ -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)
}
+128
View File
@@ -0,0 +1,128 @@
package revctrl
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
func init() {
register("git", newGit)
}
func newGit(s string) models.RevisionController {
return &git{path: strings.TrimSpace(s)}
}
type git struct {
path string
}
func (g git) Support() bool {
_, exitcode, err := g.do("rev-parse")
return exitcode == 0 && err == nil
}
func (g git) Root() (string, error) {
s, _, err := g.do("rev-parse", "--show-toplevel")
return s, err
}
func (g git) Head() (string, error) {
s, _, err := g.do("rev-list", "-n 1", "HEAD")
return s, err
}
func (g git) History(commit string) ([]string, error) {
s, _, err := g.do("rev-list", "--reverse", fmt.Sprintf("%s..HEAD", commit))
return strings.Fields(s), err
}
func (g git) Subject(commit string) string {
s, exitcode, err := g.do("log", "-1", "--pretty=%s", commit)
if exitcode > 0 || err != nil {
return ""
}
return s
}
func (g git) Author(commit string) string {
s, exitcode, err := g.do("log", "-1", "--pretty=%an", commit)
if exitcode > 0 || err != nil {
return ""
}
return s
}
func (g git) Date(commit string) string {
s, exitcode, err := g.do("log", "-1", "--pretty=%as", commit)
if exitcode > 0 || err != nil {
return ""
}
return s
}
func (g git) Drop(commit string) error {
_, exitcode, err := g.do("rebase", "--onto", commit+"^", commit)
if exitcode > 0 {
return fmt.Errorf("failed to drop commit %s", commit)
}
return err
}
func (g git) Exists(commit string) bool {
_, exitcode, err := g.do("merge-base", "--is-ancestor", commit, "HEAD")
return exitcode == 0 && err == nil
}
func (g git) Clean() bool {
// is a rebase in progress?
dirs := []string{"rebase-merge", "rebase-apply"}
for _, dir := range dirs {
relPath, _, err := g.do("rev-parse", "--git-path", dir)
if err == nil {
if _, err := os.Stat(filepath.Join(g.path, relPath)); !os.IsNotExist(err) {
log.Errorf("%s exists: another rebase in progress..", dir)
return false
}
}
}
// are there unstaged changes?
s, exitcode, err := g.do("diff-index", "HEAD")
return len(s) == 0 && exitcode == 0 && err == nil
}
func (g git) CreateWorktree(target, commit string) error {
_, exitcode, err := g.do("worktree", "add", target, commit)
if exitcode > 0 {
return fmt.Errorf("failed to create worktree in %s: %w", target, err)
}
return err
}
func (g git) DeleteWorktree(target string) error {
_, exitcode, err := g.do("worktree", "remove", target)
if exitcode > 0 {
return fmt.Errorf("failed to delete worktree in %s: %w", target, err)
}
return err
}
func (g git) ApplyCmd() string {
// TODO: should we return a *exec.Cmd instead of a string?
return fmt.Sprintf("git -C %s am -3 --empty drop", g.path)
}
func (g git) do(args ...string) (string, int, error) {
proc := exec.Command("git", "-C", g.path)
proc.Args = append(proc.Args, args...)
proc.Env = os.Environ()
result, err := proc.Output()
return string(bytes.TrimSpace(result)), proc.ProcessState.ExitCode(), err
}
+48
View File
@@ -0,0 +1,48 @@
package revctrl
import (
"errors"
"fmt"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
var ErrUnsupported = errors.New("unsupported")
type factoryFunc func(string) models.RevisionController
var controllers = map[string]factoryFunc{}
func register(controllerID string, fn factoryFunc) {
controllers[controllerID] = fn
}
func New(controllerID string, path string) (models.RevisionController, error) {
factoryFunc, ok := controllers[controllerID]
if !ok {
return nil, errors.New("cannot create revision control instance")
}
return factoryFunc(path), nil
}
type detector interface {
Support() bool
Root() (string, error)
}
func Detect(path string) (string, string, error) {
for controllerID, factoryFunc := range controllers {
rc, ok := factoryFunc(path).(detector)
if ok && rc.Support() {
log.Tracef("support found for %v", controllerID)
root, err := rc.Root()
if err != nil {
continue
}
log.Tracef("root found in %s", root)
return controllerID, root, nil
}
}
return "", "", fmt.Errorf("no supported repository found in %s", path)
}
+261
View File
@@ -0,0 +1,261 @@
package store
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path"
"strings"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
"git.sr.ht/~rjarry/aerc/lib/xdg"
"github.com/syndtr/goleveldb/leveldb"
)
const (
keyPrefix = "project."
)
var (
// versTag should be incremented when the underlying data structure
// changes.
versTag = []byte("0001")
versTagKey = []byte("version.tag")
currentKey = []byte("current.project")
)
func createKey(name string) []byte {
return []byte(keyPrefix + name)
}
func parseKey(key []byte) string {
return strings.TrimPrefix(string(key), keyPrefix)
}
func isProjectKey(key []byte) bool {
return bytes.HasPrefix(key, []byte(keyPrefix))
}
func cacheDir() (string, error) {
dir, err := os.UserCacheDir()
if err != nil {
dir = xdg.ExpandHome("~/.cache")
}
return path.Join(dir, "aerc"), nil
}
func openStorage() (*leveldb.DB, error) {
cd, err := cacheDir()
if err != nil {
return nil, fmt.Errorf("Unable to find project store directory: %w", err)
}
p := path.Join(cd, "projects")
db, err := leveldb.OpenFile(p, nil)
if err != nil {
return nil, fmt.Errorf("Unable to open project store: %w", err)
}
has, err := db.Has(versTagKey, nil)
if err != nil {
return nil, err
}
setTag := !has
if has {
vers, err := db.Get(versTagKey, nil)
if err != nil {
return nil, err
}
if !bytes.Equal(vers, versTag) {
log.Warnf("patch store: version mismatch: wipe data")
iter := db.NewIterator(nil, nil)
for iter.Next() {
err := db.Delete(iter.Key(), nil)
if err != nil {
log.Errorf("delete: %v")
}
}
iter.Release()
setTag = true
}
}
if setTag {
err := db.Put(versTagKey, versTag, nil)
if err != nil {
return nil, err
}
log.Infof("patch store: set version: %s", string(versTag))
}
return db, nil
}
func encode(p models.Project) ([]byte, error) {
return json.Marshal(p)
}
func decode(data []byte) (p models.Project, err error) {
err = json.Unmarshal(data, &p)
return
}
func Store() models.PersistentStorer {
return &instance{}
}
type instance struct{}
func (instance) StoreProject(p models.Project, overwrite bool) error {
db, err := openStorage()
if err != nil {
return err
}
defer db.Close()
key := createKey(p.Name)
has, err := db.Has(key, nil)
if err != nil {
return err
}
if has && !overwrite {
return fmt.Errorf("Project '%s' already exists.", p.Name)
}
log.Debugf("project data: %v", p)
encoded, err := encode(p)
if err != nil {
return err
}
return db.Put(key, encoded, nil)
}
func (instance) DeleteProject(name string) error {
db, err := openStorage()
if err != nil {
return err
}
defer db.Close()
key := createKey(name)
has, err := db.Has(key, nil)
if err != nil {
return err
}
if !has {
return fmt.Errorf("Project does not exist.")
}
return db.Delete(key, nil)
}
func (instance) CurrentName() (string, error) {
db, err := openStorage()
if err != nil {
return "", err
}
defer db.Close()
cur, err := db.Get(currentKey, nil)
if err != nil {
return "", err
}
return parseKey(cur), nil
}
func (instance) SetCurrent(name string) error {
db, err := openStorage()
if err != nil {
return err
}
defer db.Close()
key := createKey(name)
return db.Put(currentKey, key, nil)
}
func (instance) Current() (models.Project, error) {
db, err := openStorage()
if err != nil {
return models.Project{}, err
}
defer db.Close()
has, err := db.Has(currentKey, nil)
if err != nil {
return models.Project{}, err
}
if !has {
return models.Project{}, fmt.Errorf("No (current) project found; run 'project init' first.")
}
curProjectKey, err := db.Get(currentKey, nil)
if err != nil {
return models.Project{}, err
}
raw, err := db.Get(curProjectKey, nil)
if err != nil {
return models.Project{}, err
}
p, err := decode(raw)
if err != nil {
return models.Project{}, err
}
return p, nil
}
func (instance) Names() ([]string, error) {
db, err := openStorage()
if err != nil {
return nil, err
}
defer db.Close()
var names []string
iter := db.NewIterator(nil, nil)
for iter.Next() {
if !isProjectKey(iter.Key()) {
continue
}
names = append(names, parseKey(iter.Key()))
}
iter.Release()
return names, nil
}
func (instance) Project(name string) (models.Project, error) {
db, err := openStorage()
if err != nil {
return models.Project{}, err
}
defer db.Close()
raw, err := db.Get(createKey(name), nil)
if err != nil {
return models.Project{}, err
}
p, err := decode(raw)
if err != nil {
return models.Project{}, err
}
return p, nil
}
func (instance) Projects() ([]models.Project, error) {
var projects []models.Project
db, err := openStorage()
if err != nil {
return nil, err
}
defer db.Close()
iter := db.NewIterator(nil, nil)
for iter.Next() {
if !isProjectKey(iter.Key()) {
continue
}
p, err := decode(iter.Value())
if err != nil {
return nil, err
}
projects = append(projects, p)
}
iter.Release()
return projects, nil
}
+65
View File
@@ -0,0 +1,65 @@
package pama
import (
"fmt"
"regexp"
"time"
"git.sr.ht/~rjarry/aerc/lib/log"
)
func (m PatchManager) SwitchProject(name string) error {
c, err := m.CurrentProject()
if err == nil {
if c.Name == name {
return nil
}
}
names, err := m.store().Names()
if err != nil {
return storeErr(err)
}
found := false
for _, n := range names {
if n == name {
found = true
break
}
}
if !found {
return fmt.Errorf("Project '%s' not found", name)
}
return storeErr(m.store().SetCurrent(name))
}
var switchDebouncer *time.Timer
func DebouncedSwitchProject(name string) {
if switchDebouncer != nil {
if switchDebouncer.Stop() {
log.Debugf("pama: switch debounced")
}
}
if name == "" {
return
}
switchDebouncer = time.AfterFunc(500*time.Millisecond, func() {
if err := New().SwitchProject(name); err != nil {
log.Debugf("could not switch to project %s: %v",
name, err)
} else {
log.Debugf("project switch to project %s", name)
}
})
}
var fromSubject = regexp.MustCompile(
`\[\s*(RFC|DRAFT|[Dd]raft)*\s*(PATCH|[Pp]atch)\s+([^\s\]]+)\s*[vV]*[0-9/]*\s*\] `)
func FromSubject(s string) string {
matches := fromSubject.FindStringSubmatch(s)
if len(matches) >= 3 {
return matches[3]
}
return ""
}
+67
View File
@@ -0,0 +1,67 @@
package pama_test
import (
"testing"
"git.sr.ht/~rjarry/aerc/lib/pama"
)
func TestFromSubject(t *testing.T) {
tests := []struct {
s string
want string
}{
{
s: "[PATCH aerc] pama: new patch",
want: "aerc",
},
{
s: "[PATCH aerc v2] pama: new patch",
want: "aerc",
},
{
s: "[PATCH aerc 1/2] pama: new patch",
want: "aerc",
},
{
s: "[Patch aerc] pama: new patch",
want: "aerc",
},
{
s: "[patch aerc] pama: new patch",
want: "aerc",
},
{
s: "[RFC PATCH aerc] pama: new patch",
want: "aerc",
},
{
s: "[DRAFT PATCH aerc] pama: new patch",
want: "aerc",
},
{
s: "RE: [PATCH aerc v1] pama: new patch",
want: "aerc",
},
{
s: "[PATCH] pama: new patch",
want: "",
},
{
s: "just a subject line",
want: "",
},
{
s: "just a subject line with unrelated [asdf aerc v1]",
want: "",
},
}
for _, test := range tests {
got := pama.FromSubject(test.s)
if got != test.want {
t.Errorf("failed to get name from '%s': "+
"got '%s', want '%s'", test.s, got, test.want)
}
}
}
+61
View File
@@ -0,0 +1,61 @@
package pama
import (
"fmt"
"git.sr.ht/~rjarry/aerc/lib/log"
)
// Unlink removes provided project
func (m PatchManager) Unlink(name string) error {
store := m.store()
names, err := m.Names()
if err != nil {
return err
}
index := -1
for i, s := range names {
if s == name {
index = i
break
}
}
if index < 0 {
return fmt.Errorf("Project '%s' not found", name)
}
cur, err := store.CurrentName()
if err == nil && cur == name {
var next string
for _, s := range names {
if name != s {
next = s
break
}
}
err = store.SetCurrent(next)
if err != nil {
return storeErr(err)
}
}
p, err := store.Project(name)
if err == nil && isWorktree(p) {
err = m.deleteWorktree(p)
if err != nil {
log.Errorf("failed to delete worktree: %v", err)
}
err = store.SetCurrent(p.Worktree.Name)
if err != nil {
log.Errorf("failed to set current project: %v", err)
}
}
return storeErr(m.store().DeleteProject(name))
}
func (m PatchManager) Names() ([]string, error) {
names, err := m.store().Names()
return names, storeErr(err)
}
+88
View File
@@ -0,0 +1,88 @@
package pama
import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
"git.sr.ht/~rjarry/aerc/lib/xdg"
)
func cacheDir() (string, error) {
dir, err := os.UserCacheDir()
if err != nil {
dir = xdg.ExpandHome("~/.cache")
}
return path.Join(dir, "aerc"), nil
}
func makeWorktreeName(baseProject, tag string) string {
unique, err := generateTag(4)
if err != nil {
log.Infof("could not generate unique id: %v", err)
}
return strings.Join([]string{baseProject, "worktree", tag, unique}, "_")
}
func isWorktree(p models.Project) bool {
return p.Worktree.Name != "" && p.Worktree.Root != ""
}
func (m PatchManager) CreateWorktree(p models.Project, commitID, tag string,
) (models.Project, error) {
var w models.Project
if isWorktree(p) {
return w, fmt.Errorf("This is already a worktree.")
}
w.RevctrlID = p.RevctrlID
w.Base = models.Commit{ID: commitID}
w.Name = makeWorktreeName(p.Name, tag)
w.Worktree = models.WorktreeParent{Name: p.Name, Root: p.Root}
dir, err := cacheDir()
if err != nil {
return p, err
}
w.Root = filepath.Join(dir, "worktrees", w.Name)
rc, err := m.rc(p.RevctrlID, p.Root)
if err != nil {
return p, revErr(err)
}
err = rc.CreateWorktree(w.Root, w.Base.ID)
if err != nil {
return p, revErr(err)
}
err = m.store().StoreProject(w, true)
if err != nil {
return p, storeErr(err)
}
return w, nil
}
func (m PatchManager) deleteWorktree(p models.Project) error {
if !isWorktree(p) {
return nil
}
rc, err := m.rc(p.RevctrlID, p.Worktree.Root)
if err != nil {
return revErr(err)
}
err = rc.DeleteWorktree(p.Root)
if err != nil {
return revErr(err)
}
return nil
}