Fetch before Merge and push the squash commit to origin

This commit is contained in:
Ian Gulliver
2026-10-14 22:45:02 -07:00
parent 4ffdc1e517
commit 8825d38695
17 changed files with 352 additions and 120 deletions
+4 -2
View File
@@ -6,7 +6,7 @@
- `api/openapi.yaml` is the API source of truth. `go generate ./internal/api` regenerates `internal/api/api.gen.go`, whose types are also the store's model types.
- `internal/store`: SQLite (WAL). Every mutation and its event commit in one transaction; events are published to SSE subscribers in `seq` order.
- `internal/server`: HTTP handlers, identity, SSE.
- `internal/git`: worktrees, the task changes view (see "Changes"), unmerged-change summaries and squash merges.
- `internal/git`: worktrees, the task changes view (see "Changes"), unmerged-change summaries, squash merges and pushes.
- `internal/agents`: the agent session manager. It owns the running Claude processes and records their activity as events.
- `internal/claude`: the Claude Code stream-json client (see `docs/claude-protocol.md`).
- `internal/push`: web push notifications for new inbox items (see "Notifications").
@@ -26,7 +26,9 @@ A task ends `merged` or `discarded`; either way its sessions are stopped, its wo
A task's `merge_title` is the title of the squash commit Merge makes. It is the task title until the agent calls the `set_merge_title` MCP tool (`internal/agents/mergein.go`), which it may do at any point: one line of at most 72 characters (`maxMergeTitle`), stored in `tasks.merge_title` (empty there means "use the title"), recorded as a `task.merge_title` event that the transcript shows as a note. Malformed titles are rejected with a corrective message, and so is a call once the task is closed. The Merge confirm dialog quotes the title the commit will get.
`POST /api/tasks/{id}/merge` starts the same turn and records, in memory, that the task is to be merged by the requesting user, who must have a git identity (400 otherwise). When that turn ends normally (not interrupted, not an error), byre squashes the worktree, now containing the default branch, onto it with `git merge-tree --merge-base`, makes one commit titled with the task's `merge_title` with `git commit-tree`, authored and committed by the merging user with no agent attribution, runs `git merge --ff-only` in the project's checkout (which must have the default branch checked out), then closes the task. Nothing is pushed. When the merge changes nothing, no commit is made. If the squash fails, for example because the default branch moved again during the turn, byre files an error in the inbox and the task stays active. An interrupted or failed turn, or a byre restart during it, leaves the task active without squashing. `POST /api/tasks/{id}/discard` removes everything without merging.
`POST /api/tasks/{id}/merge` starts the same turn and records, in memory, that the task is to be merged by the requesting user, who must have a git identity (400 otherwise). When that turn ends normally (not interrupted, not an error), byre squashes the worktree, now containing the default branch, onto it with `git merge-tree --merge-base`, makes one commit titled with the task's `merge_title` with `git commit-tree`, authored and committed by the merging user with no agent attribution, and pushes that commit to the default branch on the `origin` remote of the project's checkout (`git push --porcelain origin <commit>:refs/heads/<default branch>`, run in the checkout). Only once the push is accepted does it run `git merge --ff-only` in the project's checkout (which must have the default branch checked out) and close the task; the `task.merged` event carries the commit and `pushed` (`origin/<default branch>`), and the transcript shows both. When the merge changes nothing, no commit is made and nothing is pushed.
Before starting the turn, Merge runs `git fetch origin <default branch>` and `git merge --ff-only origin/<default branch>` in the checkout, so the agent merges in what the remote has; the request waits for the fetch, and a failure (network, or a checkout with commits the remote lacks) is returned as the request's error. If the push is still refused, for example because someone pushed during the turn, or fails for any other reason, byre files the error in the inbox and the task stays active; clicking Merge again fetches and goes round once more. A refused push leaves the checkout's default branch where it was, since the squash commit was never moved onto it. If the squash fails, for example because the default branch moved again during the turn, byre also files an error and the task stays active. An interrupted or failed turn, or a byre restart during it, leaves the task active without squashing. `POST /api/tasks/{id}/discard` removes everything without merging.
## Running as a service
+2 -2
View File
@@ -484,8 +484,8 @@ Record transcripts from the spike as the seed of the contract test suite.
- **Not done:** snapshots, timeline scrubbing, terminals, file browser, previews.
- **M3 — Second vendor and review.** Codex adapter with the same interface coverage, review profiles, review triggers, visual review output, cross-vendor review.
- **M4 — Merging and history.** Full merge cycle with squash, authorship and co-author trailers, drafted commit messages, merge-resolution stage, git notes, task completion and archive, archive search, GitHub Issues integration.
- **Done:** Merge and Discard replace "Complete task". Merge squashes the worktree, uncommitted files included, into one commit on the default branch, authored by the merging user, and fast-forwards the project checkout; it doesn't push, and it refuses while a turn is running. Merge first has the agent merge the default branch in (also available alone as "Merge main in"), then squashes when that turn ends. The squash commit's title is the task's merge title: the task title until the agent sets another with the `set_merge_title` MCP tool, which the merge-in prompt asks it to do when the current title no longer describes the branch.
- **Not done:** co-author trailers, human editing of the drafted message before confirming, merge-resolution stage, git notes, push, repeated merge cycles, archive search, GitHub Issues.
- **Done:** Merge and Discard replace "Complete task". Merge squashes the worktree, uncommitted files included, into one commit on the default branch, authored by the merging user, pushes it to the project's `origin`, and then fast-forwards the project checkout; it refuses while a turn is running. Merge first has the agent merge the default branch in (also available alone as "Merge main in"), then squashes when that turn ends. Merge fetches the remote's default branch into the checkout before the merge-in; a refused or failed push is filed as an error and the task stays active for another Merge. The squash commit's title is the task's merge title: the task title until the agent sets another with the `set_merge_title` MCP tool, which the merge-in prompt asks it to do when the current title no longer describes the branch.
- **Not done:** co-author trailers, human editing of the drafted message before confirming, merge-resolution stage, git notes (and pushing the notes ref), repeated merge cycles, archive search, GitHub Issues.
- **M5 — Workflows.** Workflow definitions and storage, stages, gates, bounded loops, plan artifact, eval stages, task intake, mid-task swapping.
- **M6 — Fan-out.** `export_context` and handoff bundles, same-vendor forks, cross-vendor handoffs, fork-and-compare, just-in-time fan-out with limits.
- **M7 — Inbox triage.**
+70 -2
View File
@@ -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)
+21 -3
View File
@@ -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
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+31 -7
View File
@@ -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) {
+113
View File
@@ -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)
}
+2 -2
View File
@@ -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) {
+1 -1
View File
@@ -615,7 +615,7 @@ function renderEvent(ev, ctx) {
case "task.merge_title":
return h("div", { class: "note" }, `${ev.actor} set the merge commit title to "${String(p["merge_title"])}" `, when);
case "task.merged":
return h("div", { class: "note" }, `${ev.actor} merged the task${p["commit"] ? ` as ${String(p["commit"]).slice(0, 7)}` : ""} `, when);
return h("div", { class: "note" }, `${ev.actor} merged the task${p["commit"] ? ` as ${String(p["commit"]).slice(0, 7)}` : ""}${p["pushed"] ? ` and pushed it to ${String(p["pushed"])}` : ""} `, when);
case "task.discarded":
return h("div", { class: "note" }, `${ev.actor} discarded the task `, when);
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -707,7 +707,12 @@ function renderEvent(ev: ByreEvent, ctx: Ctx): HTMLElement | null {
return h("div", { class: "note" }, `${ev.actor} set the merge commit title to "${String(p["merge_title"])}" `, when);
case "task.merged":
return h("div", { class: "note" }, `${ev.actor} merged the task${p["commit"] ? ` as ${String(p["commit"]).slice(0, 7)}` : ""} `, when);
return h(
"div",
{ class: "note" },
`${ev.actor} merged the task${p["commit"] ? ` as ${String(p["commit"]).slice(0, 7)}` : ""}${p["pushed"] ? ` and pushed it to ${String(p["pushed"])}` : ""} `,
when,
);
case "task.discarded":
return h("div", { class: "note" }, `${ev.actor} discarded the task `, when);