Skip to content

Commit

Permalink
feat(stack reorder): Open editor to customize reorder plan (#167)
Browse files Browse the repository at this point in the history
This is kind of the bare minimum to make `av stack reorder` work.

There are still a few issues that need to be resolved before it can be released, but at the very least, reordering commits and moving them between branches works.

# Test plan

Created a two stack structure like
```
* 2023-06-06-two-b d11e918  (HEAD -> 2023-06-06-two)
| 
* 2023-06-06-two-a 442caa6 
| 
* 2023-06-06-one-b 4867243  (2023-06-06-one)
| 
* 2023-06-06-one-a f9b4731 
| 
* 2023-05-30-one-a (#957) 87232f0  (origin/main, origin/HEAD, main)
```

Running `av stack reorder` generates the default (noop) plan:
```
stack-branch 2023-06-06-one --trunk main@87232f0dbf7bf64004ad9e053ae75e6d096c416c
pick f9b4731  # 2023-06-06-one-a
pick 4867243  # 2023-06-06-one-b

stack-branch 2023-06-06-two --parent 2023-06-06-one
pick 442caa6  # 2023-06-06-two-a
pick d11e918  # 2023-06-06-two-b
```

Can edit that to move a commit from `two` to `one`:
```
stack-branch 2023-06-06-one --trunk main@87232f0dbf7bf64004ad9e053ae75e6d096c416c
pick f9b4731  # 2023-06-06-one-a
pick 4867243  # 2023-06-06-one-b
pick 442caa6  # 2023-06-06-two-a

stack-branch 2023-06-06-two --parent 2023-06-06-one
pick d11e918  # 2023-06-06-two-b
```

Running the reorder then outputs:
```
Starting branch 2023-06-06-one at 87232f0
  - applied commit f9b4731 without conflict (HEAD is now at f9b4731)
  - applied commit 4867243 without conflict (HEAD is now at 4867243)
  - applied commit 442caa6 without conflict (HEAD is now at 442caa6)
Starting branch 2023-06-06-two at 442caa6
  - applied commit d11e918 without conflict (HEAD is now at d11e918)
Reorder complete!

The stack was reordered successfully.
```

and the branches are updated accordingly:
```
* 2023-06-06-two-b d11e918  (HEAD -> 2023-06-06-two)
| 
* 2023-06-06-two-a 442caa6  (2023-06-06-one)
| 
* 2023-06-06-one-b 4867243 
| 
* 2023-06-06-one-a f9b4731 
| 
* 2023-05-30-one-a (#957) 87232f0  (origin/main, origin/HEAD, main)
```
  • Loading branch information
twavv committed Jun 7, 2023
1 parent 9bbc39a commit acc644f
Show file tree
Hide file tree
Showing 8 changed files with 288 additions and 17 deletions.
139 changes: 135 additions & 4 deletions cmd/av/stack_reorder.go
@@ -1,9 +1,17 @@
package main

import (
"fmt"
"os"
"strings"

"emperror.dev/errors"
"github.com/aviator-co/av/internal/actions"
"github.com/aviator-co/av/internal/config"
"github.com/aviator-co/av/internal/meta"
"github.com/aviator-co/av/internal/reorder"
"github.com/aviator-co/av/internal/utils/colors"
"github.com/sirupsen/logrus"

"github.com/spf13/cobra"
)

Expand All @@ -15,8 +23,8 @@ var stackReorderFlags struct {
const stackReorderDoc = `
Interactively reorder the stack.
This is analogous to git rebase --interactive but operates on the stack (rather
than branch) level.
This is analogous to git rebase --interactive but operates across all branches
in the stack.
Branches can be re-arranged within the stack and commits can be edited,
squashed, dropped, or moved within the stack.
Expand All @@ -27,8 +35,130 @@ var stackReorderCmd = &cobra.Command{
Short: "reorder the stack",
Hidden: true,
Long: strings.TrimSpace(stackReorderDoc),
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return errors.New("not implemented")
if config.Version != config.VersionDev {
logrus.Fatal("av stack reorder is not yet implemented")
}
repo, err := getRepo()
if err != nil {
return err
}
db, err := getDB(repo)
if err != nil {
return err
}

continuation, err := reorder.ReadContinuation(repo)
if os.IsNotExist(err) {
if stackReorderFlags.Continue || stackReorderFlags.Abort {
_, _ = fmt.Fprint(os.Stderr,
colors.Failure("ERROR: no reorder in progress\n"),
)
return actions.ErrExitSilently{ExitCode: 127}
}
} else if err != nil {
return err
}

var state *reorder.State
if stackReorderFlags.Abort {
// TODO: Handle clearing any cherry-pick state and whatnot.
// TODO: --abort should probably reset the state of each branch
// associated with the reorder to the original. It might be worth
// storing some history and allow the user to do --undo to restore
// their Git state to the state before the reorder.
return reorder.WriteContinuation(repo, nil)
} else if stackReorderFlags.Continue {
state = continuation.State
} else {
if continuation != nil {
_, _ = fmt.Fprint(os.Stderr,
colors.Failure("ERROR: reorder already in progress\n"),
colors.Failure(" use --continue or --abort to continue or abort the reorder\n"),
)
return actions.ErrExitSilently{ExitCode: 127}
}
tx := db.ReadTx()
currentBranch, err := repo.CurrentBranchName()
if err != nil {
return err
}
root, ok := meta.Root(tx, currentBranch)
if !ok {
_, _ = fmt.Fprint(os.Stderr,
colors.Failure("ERROR: branch "), colors.UserInput(currentBranch),
colors.Failure(" is not part of a stack\n"),
)
return actions.ErrExitSilently{ExitCode: 127}
}
plan, err := reorder.CreatePlan(repo, db.ReadTx(), root)
if err != nil {
return err
}

// TODO:
// What should we do if the plan removes branches? Currently,
// we just don't edit those branches. So if a user edits
// sb one
// pick 1a
// sb two
// pick 2a
// and deletes the line for `sb two`, then the reorder will modify
// one to contain 1a/2a, but we don't modify two so it'll still be
// considered stacked on top of one.
// We can probably delete the branch and also emit a message to the
// user to the effect of
// Deleting branch `two` because it was removed from the reorder.
// To restore the branch, run `git switch -C two <OLD HEAD>`.
// just to make sure they can recover their work. (They already would
// be able to using `git reflog` but generally only advanced Git
// users think to do that).
plan, err = reorder.EditPlan(repo, plan)
if err != nil {
return err
}
if len(plan) == 0 {
_, _ = fmt.Fprint(os.Stderr,
colors.Failure("ERROR: reorder plan is empty\n"),
)
return actions.ErrExitSilently{ExitCode: 127}
}

logrus.WithFields(logrus.Fields{
"plan": plan,
"current_branch": currentBranch,
"root_branch": root,
}).Debug("created reorder plan")
state = &reorder.State{Commands: plan}
}

continuation, err = reorder.Reorder(reorder.Context{
Repo: repo,
DB: db,
State: state,
Output: os.Stderr,
})
if err != nil {
return err
}
if continuation == nil {
_, _ = fmt.Fprint(os.Stderr,
colors.Success("\nThe stack was reordered successfully.\n"),
)
return nil
}

if err := reorder.WriteContinuation(repo, continuation); err != nil {
return err
}
_, _ = fmt.Fprint(os.Stderr,
colors.Warning("\nThe reorder was interrupted by a conflict.\n"),
colors.Warning("Resolve the conflict and run "),
colors.CliCmd("av stack reorder --continue"),
colors.Warning(" to continue.\n"),
)
return actions.ErrExitSilently{ExitCode: 1}
},
}

Expand All @@ -37,4 +167,5 @@ func init() {
BoolVar(&stackReorderFlags.Continue, "continue", false, "continue a previous reorder")
stackReorderCmd.Flags().
BoolVar(&stackReorderFlags.Abort, "abort", false, "abort a previous reorder")
stackReorderCmd.MarkFlagsMutuallyExclusive("continue", "abort")
}
16 changes: 13 additions & 3 deletions internal/editor/editor.go
Expand Up @@ -21,6 +21,9 @@ type Config struct {
TmpFilePattern string
// The prefix used to identify comments in the text.
CommentPrefix string
// If true, strip comments from the end of lines. If false, only whole lines
// that are comments will be stripped.
EndOfLineComments bool
// The editor command to be used.
// If empty, the git default editor will be used.
Command string
Expand Down Expand Up @@ -106,10 +109,17 @@ func parseResult(path string, config Config) (string, error) {
res := bytes.NewBuffer(nil)
for scan.Scan() {
line := scan.Text()
if !strings.HasPrefix(line, config.CommentPrefix) {
res.WriteString(line)
res.WriteString("\n")
if strings.HasPrefix(line, config.CommentPrefix) {
// Skip this line altogether (including the newline).
continue
}
// This currently doesn't include any way to escape comments, but that's
// probably fine for us for now.
if config.EndOfLineComments {
line, _, _ = strings.Cut(line, config.CommentPrefix)
}
res.WriteString(line)
res.WriteByte('\n')
}
return res.String(), nil
}
27 changes: 18 additions & 9 deletions internal/editor/editor_test.go
Expand Up @@ -8,17 +8,18 @@ import (

func TestEditor(t *testing.T) {
type test struct {
name string
command string
in string
out string
error bool
name string
command string
in string
out string
error bool
eolcomments bool
}
for _, tt := range []test{
{
name: "with comments",
command: "true",
in: "Hello world!\n\nBonjour le monde!\n%% This is a commend\n",
in: "Hello world!\n\nBonjour le monde!\n%% This is a comment\n",
out: "Hello world!\n\nBonjour le monde!\n",
},
{
Expand All @@ -33,11 +34,19 @@ func TestEditor(t *testing.T) {
in: "Hello world!\n\nBonjour le monde!\n",
error: true,
},
{
name: "eolcomments",
command: "true",
in: "Hello world! %% One\n\nBonjour le monde! %% Two\n%% This is a comment\n",
out: "Hello world! \n\nBonjour le monde! \n",
eolcomments: true,
},
} {
res, err := Launch(nil, Config{
Text: tt.in,
CommentPrefix: "%%",
Command: tt.command,
Text: tt.in,
CommentPrefix: "%%",
Command: tt.command,
EndOfLineComments: tt.eolcomments,
})
if tt.error {
require.Error(t, err, "expected error while executing `%s`", tt.command)
Expand Down
13 changes: 12 additions & 1 deletion internal/meta/branch.go
Expand Up @@ -65,7 +65,6 @@ func (b *Branch) UnmarshalJSON(bytes []byte) error {
return err
}

logrus.Debugf("parsed branch metadata: %s => %#+v %#+v", bytes, d, b)
return nil
}

Expand Down Expand Up @@ -129,6 +128,18 @@ func SubsequentBranches(tx ReadTx, name string) []string {
return res
}

// Root determines the stack root of a branch.
func Root(tx ReadTx, name string) (string, bool) {
for name != "" {
branch, _ := tx.Branch(name)
if branch.Parent.Trunk {
return name, true
}
name = branch.Parent.Name
}
return "", false
}

// Trunk determines the trunk of a branch.
func Trunk(tx ReadTx, name string) (string, bool) {
for name != "" {
Expand Down
49 changes: 49 additions & 0 deletions internal/reorder/editor.go
@@ -0,0 +1,49 @@
package reorder

import (
"strings"

"github.com/aviator-co/av/internal/editor"
"github.com/aviator-co/av/internal/git"
"github.com/aviator-co/av/internal/utils/typeutils"
)

// EditPlan opens the user's editor and allows them to edit the plan.
func EditPlan(repo *git.Repo, plan []Cmd) ([]Cmd, error) {
text := strings.Builder{}
for i, cmd := range plan {
if i > 0 && typeutils.Is[StackBranchCmd](cmd) {
// Write an extra newline at the start of each branch command
// (other than the first) to create a visual separation between
// branches.
text.WriteString("\n")
}
text.WriteString(cmd.String())
text.WriteString("\n")
}

res, err := editor.Launch(repo, editor.Config{
Text: text.String(),
CommentPrefix: "#",
EndOfLineComments: true,
})
if err != nil {
return nil, err
}

var newPlan []Cmd
lines := strings.Split(res, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
cmd, err := ParseCmd(line)
if err != nil {
return nil, err
}
newPlan = append(newPlan, cmd)
}

return newPlan, nil
}
46 changes: 46 additions & 0 deletions internal/reorder/reorder.go
@@ -1,8 +1,12 @@
package reorder

import (
"encoding/json"
"fmt"
"os"
"path/filepath"

"github.com/aviator-co/av/internal/git"

"emperror.dev/errors"
"github.com/aviator-co/av/internal/utils/colors"
Expand Down Expand Up @@ -33,3 +37,45 @@ func Reorder(ctx Context) (*Continuation, error) {
type Continuation struct {
State *State
}

const stateFileName = "stack-reorder.state.json"

// ReadContinuation reads a continuation from the state file.
// Returns the raw error returned by os.Open if the file couldn't be opened.
// Use os.IsNotExist to check if the continuation doesn't exist.
func ReadContinuation(repo *git.Repo) (*Continuation, error) {
file, err := os.Open(filepath.Join(repo.AvDir(), stateFileName))
if err != nil {
return nil, err
}
defer func() { _ = file.Close() }()

decoder := json.NewDecoder(file)
var continuation Continuation
err = decoder.Decode(&continuation)
if err != nil {
return nil, err
}

return &continuation, nil
}

// WriteContinuation writes a continuation to the state file.
// If a nil continuation is passed, the state file is deleted.
func WriteContinuation(repo *git.Repo, continuation *Continuation) error {
if continuation == nil {
return os.Remove(filepath.Join(repo.AvDir(), stateFileName))
}

file, err := os.Create(filepath.Join(repo.AvDir(), stateFileName))
if err != nil {
return err
}
enc := json.NewEncoder(file)
enc.SetIndent("", " ")
if err := enc.Encode(continuation); err != nil {
_ = file.Close()
return err
}
return file.Close()
}

0 comments on commit acc644f

Please sign in to comment.