Fetch before Merge and push the squash commit to origin
This commit is contained in:
@@ -32,6 +32,7 @@ type env struct {
|
||||
events chan api.Event
|
||||
user api.User
|
||||
repo string
|
||||
remote string
|
||||
project api.Project
|
||||
task api.Task
|
||||
|
||||
@@ -61,7 +62,11 @@ func newEnv(t *testing.T) *env {
|
||||
}
|
||||
|
||||
e.repo = scratchRepo(t)
|
||||
e.project = api.Project{Id: store.NewID(), Name: "demo", RepoPath: e.repo, DefaultBranch: "main", Remote: "none", CreatedBy: login, CreatedAt: time.Now().UTC()}
|
||||
e.remote = filepath.Join(t.TempDir(), "remote.git")
|
||||
gitOut(t, e.repo, "init", "--quiet", "--bare", "--initial-branch=main", e.remote)
|
||||
gitOut(t, e.repo, "remote", "add", git.Remote, e.remote)
|
||||
gitOut(t, e.repo, "push", "--quiet", git.Remote, "main")
|
||||
e.project = api.Project{Id: store.NewID(), Name: "demo", RepoPath: e.repo, DefaultBranch: "main", Remote: e.remote, CreatedBy: login, CreatedAt: time.Now().UTC()}
|
||||
err = e.store.CreateProject(ctx, e.project)
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
@@ -597,7 +602,10 @@ func TestMergeResolvesConflict(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
e.waitFor("task merged", func(ev api.Event) bool { return ev.Kind == "task.merged" })
|
||||
merged := e.waitFor("task merged", func(ev api.Event) bool { return ev.Kind == "task.merged" })
|
||||
if pushed := merged[len(merged)-1].Payload["pushed"]; pushed != "origin/main" {
|
||||
t.Fatalf("task.merged pushed %v, want origin/main", pushed)
|
||||
}
|
||||
|
||||
task, err := e.store.GetTask(e.ctx, e.task.Id)
|
||||
if err != nil {
|
||||
@@ -609,6 +617,9 @@ func TestMergeResolvesConflict(t *testing.T) {
|
||||
if head := gitOut(t, e.repo, "rev-parse", "main"); head != *task.MergeCommit {
|
||||
t.Fatalf("main at %s, want merge commit %s", head, *task.MergeCommit)
|
||||
}
|
||||
if head := gitOut(t, e.remote, "rev-parse", "main"); head != *task.MergeCommit {
|
||||
t.Fatalf("remote main at %s, want merge commit %s", head, *task.MergeCommit)
|
||||
}
|
||||
if parent := gitOut(t, e.repo, "rev-parse", "main^"); parent != moved {
|
||||
t.Fatalf("merge commit parent %s, want %s", parent, moved)
|
||||
}
|
||||
@@ -635,6 +646,63 @@ func TestMergeResolvesConflict(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeFetchesMovedRemote(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
m := e.manager()
|
||||
|
||||
_, err := m.Create(e.ctx, e.task, e.user, `Create a file task.txt containing exactly the word task.`, testModel)
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
e.waitIdle()
|
||||
|
||||
other := t.TempDir()
|
||||
gitOut(t, other, "clone", "--quiet", e.remote, ".")
|
||||
err = os.WriteFile(filepath.Join(other, "elsewhere.txt"), []byte("elsewhere\n"), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("write elsewhere.txt: %v", err)
|
||||
}
|
||||
gitOut(t, other, "add", "elsewhere.txt")
|
||||
gitOut(t, other, "commit", "--message", "pushed elsewhere")
|
||||
gitOut(t, other, "push", "--quiet", "origin", "main")
|
||||
moved := gitOut(t, other, "rev-parse", "HEAD")
|
||||
|
||||
_, err = m.Merge(e.ctx, e.task, e.project, e.user)
|
||||
if err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
events := e.waitFor("task merged", func(ev api.Event) bool { return ev.Kind == "task.merged" })
|
||||
|
||||
for _, ev := range events {
|
||||
if ev.Kind == "inbox.created" && ev.Payload["kind"] == string(api.SessionError) {
|
||||
t.Fatalf("merge filed an error: %v", ev.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
task, err := e.store.GetTask(e.ctx, e.task.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("get task: %v", err)
|
||||
}
|
||||
if task.Status != api.TaskStatusMerged || task.MergeCommit == nil {
|
||||
t.Fatalf("task after merge %+v", task)
|
||||
}
|
||||
for _, dir := range []string{e.repo, e.remote} {
|
||||
if head := gitOut(t, dir, "rev-parse", "main"); head != *task.MergeCommit {
|
||||
t.Fatalf("%s main at %s, want merge commit %s", dir, head, *task.MergeCommit)
|
||||
}
|
||||
}
|
||||
if parent := gitOut(t, e.remote, "rev-parse", "main^"); parent != moved {
|
||||
t.Fatalf("merge commit parent %s, want the moved remote head %s", parent, moved)
|
||||
}
|
||||
for _, f := range []string{"task.txt", "elsewhere.txt"} {
|
||||
_, err = os.Stat(filepath.Join(e.repo, f))
|
||||
if err != nil {
|
||||
t.Fatalf("%s missing from main after merge: %v", f, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskTool(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
|
||||
@@ -45,6 +45,11 @@ func (m *Manager) Merge(ctx context.Context, task api.Task, project api.Project,
|
||||
return api.AgentSession{}, ErrNoGitIdentity
|
||||
}
|
||||
|
||||
err := git.FetchFastForward(project.RepoPath, project.DefaultBranch)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.merges[task.Id] = user.Login
|
||||
m.mu.Unlock()
|
||||
@@ -74,7 +79,7 @@ func (m *Manager) finishMerge(l *live, login string) {
|
||||
}
|
||||
|
||||
slog.Error("finish merge", "task", l.taskID, "err", err)
|
||||
m.fileError(l, "The agent merged the default branch in, but squashing onto it failed: "+err.Error())
|
||||
m.fileError(l, "Merging the task failed: "+err.Error())
|
||||
err = m.Deliver(ctx, l.id)
|
||||
if err != nil {
|
||||
slog.Error("deliver inbox responses", "session", l.id, "err", err)
|
||||
@@ -95,7 +100,7 @@ func (m *Manager) squashAndClose(ctx context.Context, taskID, login string) erro
|
||||
return err
|
||||
}
|
||||
|
||||
commit, err := git.SquashMerge(project.RepoPath, task.WorktreePath, project.DefaultBranch, task.MergeTitle, []string{
|
||||
commit, err := git.SquashCommit(project.RepoPath, task.WorktreePath, project.DefaultBranch, task.MergeTitle, []string{
|
||||
"GIT_AUTHOR_NAME=" + user.GitName,
|
||||
"GIT_AUTHOR_EMAIL=" + user.GitEmail,
|
||||
"GIT_COMMITTER_NAME=" + user.GitName,
|
||||
@@ -105,12 +110,25 @@ func (m *Manager) squashAndClose(ctx context.Context, taskID, login string) erro
|
||||
return err
|
||||
}
|
||||
|
||||
pushed := ""
|
||||
if commit != "" {
|
||||
err = git.Push(project.RepoPath, commit, project.DefaultBranch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pushed = git.Remote + "/" + project.DefaultBranch
|
||||
err = git.FastForward(project.RepoPath, commit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pushed %s to %s, but fast-forwarding the project checkout failed: %w", commit, pushed, err)
|
||||
}
|
||||
}
|
||||
|
||||
err = m.RemoveTask(ctx, task, project, login)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = m.store.MergeTask(ctx, taskID, commit, login)
|
||||
_, err = m.store.MergeTask(ctx, taskID, commit, pushed, login)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+16
-17
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+15
-16
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+32
-8
@@ -147,7 +147,7 @@ func UnmergedChanges(worktree, target string) (Unmerged, error) {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func SquashMerge(repo, worktree, target, message string, env []string) (string, error) {
|
||||
func SquashCommit(repo, worktree, target, message string, env []string) (string, error) {
|
||||
checkedOut, err := run(repo, "symbolic-ref", "--short", "HEAD")
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -196,16 +196,40 @@ func SquashMerge(repo, worktree, target, message string, env []string) (string,
|
||||
return "", nil
|
||||
}
|
||||
|
||||
commit, err := runEnv(repo, env, "commit-tree", lines[0], "-p", head, "-m", message)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return runEnv(repo, env, "commit-tree", lines[0], "-p", head, "-m", message)
|
||||
}
|
||||
|
||||
_, err = run(repo, "merge", "--ff-only", commit)
|
||||
const Remote = "origin"
|
||||
|
||||
var ErrRemoteMoved = errors.New("the remote's branch has moved")
|
||||
|
||||
func Push(repo, commit, branch string) error {
|
||||
out, stderr, code, err := runCode(repo, nil, "push", "--porcelain", Remote, commit+":refs/heads/"+branch)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return err
|
||||
}
|
||||
return commit, nil
|
||||
if code == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.HasPrefix(line, "!") && strings.Contains(line, "[rejected]") && (strings.Contains(line, "(fetch first)") || strings.Contains(line, "(non-fast-forward)")) {
|
||||
return fmt.Errorf("pushing %s to %s: %w", branch, Remote, ErrRemoteMoved)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("pushing %s to %s: exit status %d: %s", branch, Remote, code, strings.TrimSpace(out+"\n"+stderr))
|
||||
}
|
||||
|
||||
func FastForward(repo, rev string) error {
|
||||
_, err := run(repo, "merge", "--ff-only", rev)
|
||||
return err
|
||||
}
|
||||
|
||||
func FetchFastForward(repo, branch string) error {
|
||||
_, err := run(repo, "fetch", Remote, branch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return FastForward(repo, Remote+"/"+branch)
|
||||
}
|
||||
|
||||
func Head(dir string) (string, error) {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var testEnv = []string{
|
||||
"GIT_AUTHOR_NAME=Test",
|
||||
"GIT_AUTHOR_EMAIL=test@byre.invalid",
|
||||
"GIT_COMMITTER_NAME=Test",
|
||||
"GIT_COMMITTER_EMAIL=test@byre.invalid",
|
||||
}
|
||||
|
||||
func mustGit(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
out, err := runEnv(dir, testEnv, args...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pushFixture(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
bare := filepath.Join(t.TempDir(), "remote.git")
|
||||
mustGit(t, t.TempDir(), "init", "--quiet", "--bare", "--initial-branch=main", bare)
|
||||
|
||||
repo := t.TempDir()
|
||||
mustGit(t, repo, "init", "--quiet", "--initial-branch=main")
|
||||
mustGit(t, repo, "commit", "--quiet", "--allow-empty", "-m", "root")
|
||||
mustGit(t, repo, "remote", "add", Remote, bare)
|
||||
mustGit(t, repo, "push", "--quiet", Remote, "main")
|
||||
return repo, bare
|
||||
}
|
||||
|
||||
func moveRemote(t *testing.T, bare string) string {
|
||||
t.Helper()
|
||||
other := t.TempDir()
|
||||
mustGit(t, other, "clone", "--quiet", bare, ".")
|
||||
mustGit(t, other, "commit", "--quiet", "--allow-empty", "-m", "elsewhere")
|
||||
mustGit(t, other, "push", "--quiet", Remote, "main")
|
||||
return mustGit(t, other, "rev-parse", "HEAD")
|
||||
}
|
||||
|
||||
func squash(t *testing.T, repo, message string) string {
|
||||
t.Helper()
|
||||
tree := mustGit(t, repo, "rev-parse", "main^{tree}")
|
||||
return mustGit(t, repo, "commit-tree", tree, "-p", "main", "-m", message)
|
||||
}
|
||||
|
||||
func TestPushClean(t *testing.T) {
|
||||
repo, bare := pushFixture(t)
|
||||
commit := squash(t, repo, "task")
|
||||
|
||||
err := Push(repo, commit, "main")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := mustGit(t, bare, "rev-parse", "main"); got != commit {
|
||||
t.Fatalf("remote main at %s, want %s", got, commit)
|
||||
}
|
||||
err = FastForward(repo, commit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := mustGit(t, repo, "rev-parse", "main"); got != commit {
|
||||
t.Fatalf("local main at %s, want %s", got, commit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushRemoteMoved(t *testing.T) {
|
||||
repo, bare := pushFixture(t)
|
||||
moved := moveRemote(t, bare)
|
||||
commit := squash(t, repo, "task")
|
||||
|
||||
err := Push(repo, commit, "main")
|
||||
if !errors.Is(err, ErrRemoteMoved) {
|
||||
t.Fatalf("push onto a moved remote: %v, want ErrRemoteMoved", err)
|
||||
}
|
||||
if got := mustGit(t, bare, "rev-parse", "main"); got != moved {
|
||||
t.Fatalf("remote main at %s after a refused push, want %s", got, moved)
|
||||
}
|
||||
|
||||
err = FetchFastForward(repo, "main")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := mustGit(t, repo, "rev-parse", "main"); got != moved {
|
||||
t.Fatalf("local main at %s after fetch, want %s", got, moved)
|
||||
}
|
||||
|
||||
commit = squash(t, repo, "task again")
|
||||
err = Push(repo, commit, "main")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := mustGit(t, bare, "rev-parse", "main"); got != commit {
|
||||
t.Fatalf("remote main at %s, want %s", got, commit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushOtherFailure(t *testing.T) {
|
||||
repo, _ := pushFixture(t)
|
||||
mustGit(t, repo, "remote", "set-url", Remote, filepath.Join(t.TempDir(), "missing.git"))
|
||||
|
||||
err := Push(repo, squash(t, repo, "task"), "main")
|
||||
if err == nil || errors.Is(err, ErrRemoteMoved) {
|
||||
t.Fatalf("push to a missing remote: %v, want an error other than ErrRemoteMoved", err)
|
||||
}
|
||||
t.Logf("push error: %v", err)
|
||||
}
|
||||
@@ -452,8 +452,8 @@ func (s *Store) SetMergeTitle(ctx context.Context, id, title, actor string) erro
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) MergeTask(ctx context.Context, id, commit, actor string) (api.Task, error) {
|
||||
return s.closeTask(ctx, id, api.TaskStatusMerged, commit, actor, "task.merged", map[string]any{"id": id, "commit": commit})
|
||||
func (s *Store) MergeTask(ctx context.Context, id, commit, pushed, actor string) (api.Task, error) {
|
||||
return s.closeTask(ctx, id, api.TaskStatusMerged, commit, actor, "task.merged", map[string]any{"id": id, "commit": commit, "pushed": pushed})
|
||||
}
|
||||
|
||||
func (s *Store) DiscardTask(ctx context.Context, id, actor string) (api.Task, error) {
|
||||
|
||||
Reference in New Issue
Block a user