This commit is contained in:
Ian Gulliver
2026-10-14 21:01:38 -07:00
parent 9e741ede32
commit b2bd894677
20 changed files with 585 additions and 343 deletions
+20 -53
View File
@@ -291,49 +291,33 @@ func (s *Server) MergeTask(w http.ResponseWriter, r *http.Request, taskID string
writeStoreError(w, err)
return
}
if u.GitName == "" || u.GitEmail == "" {
writeError(w, http.StatusBadRequest, errors.New("set your git name and email (PUT /api/me) before merging"))
sess, err := s.agents.Merge(r.Context(), t, p, u)
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusAccepted, sess)
}
func (s *Server) MergeIn(w http.ResponseWriter, r *http.Request, taskID string) {
t, p, ok := s.activeTask(w, r, taskID)
if !ok {
return
}
sessions, err := s.store.ListSessions(r.Context(), t.Id)
u, err := s.store.GetUser(r.Context(), actor(r))
if err != nil {
writeStoreError(w, err)
return
}
for _, sess := range sessions {
if sess.State == api.AgentSessionStateRunning || sess.State == api.AgentSessionStateQueued {
writeError(w, http.StatusConflict, fmt.Errorf("session %s is %s; wait for the turn to end or interrupt it", sess.Id, sess.State))
return
}
}
commit, err := git.SquashMerge(p.RepoPath, t.WorktreePath, p.DefaultBranch, squashMessage(t), []string{
"GIT_AUTHOR_NAME=" + u.GitName,
"GIT_AUTHOR_EMAIL=" + u.GitEmail,
"GIT_COMMITTER_NAME=" + u.GitName,
"GIT_COMMITTER_EMAIL=" + u.GitEmail,
})
conflict := &git.ConflictError{}
if errors.As(err, &conflict) {
writeError(w, http.StatusConflict, err)
return
}
sess, err := s.agents.MergeIn(r.Context(), t, u, p.DefaultBranch)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
writeAgentError(w, err)
return
}
if !s.removeTask(w, r, t, p) {
return
}
t, err = s.store.MergeTask(r.Context(), taskID, commit, actor(r))
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, t)
writeJSON(w, http.StatusAccepted, sess)
}
func (s *Server) DiscardTask(w http.ResponseWriter, r *http.Request, taskID string) {
@@ -342,11 +326,13 @@ func (s *Server) DiscardTask(w http.ResponseWriter, r *http.Request, taskID stri
return
}
if !s.removeTask(w, r, t, p) {
err := s.agents.RemoveTask(r.Context(), t, p, actor(r))
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
t, err := s.store.DiscardTask(r.Context(), taskID, actor(r))
t, err = s.store.DiscardTask(r.Context(), taskID, actor(r))
if err != nil {
writeStoreError(w, err)
return
@@ -354,10 +340,6 @@ func (s *Server) DiscardTask(w http.ResponseWriter, r *http.Request, taskID stri
writeJSON(w, http.StatusOK, t)
}
func squashMessage(t api.Task) string {
return t.Title
}
func (s *Server) activeTask(w http.ResponseWriter, r *http.Request, taskID string) (api.Task, api.Project, bool) {
t, err := s.store.GetTask(r.Context(), taskID)
if err != nil {
@@ -378,21 +360,6 @@ func (s *Server) activeTask(w http.ResponseWriter, r *http.Request, taskID strin
return t, p, true
}
func (s *Server) removeTask(w http.ResponseWriter, r *http.Request, t api.Task, p api.Project) bool {
err := s.agents.StopTask(r.Context(), t.Id, actor(r))
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return false
}
err = git.RemoveWorktree(p.RepoPath, t.WorktreePath, t.Branch)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return false
}
return true
}
func (s *Server) GetStatus(w http.ResponseWriter, r *http.Request) {
c, err := s.store.Counts(r.Context())
if err != nil {
+2 -71
View File
@@ -259,80 +259,11 @@ func TestMergeTask(t *testing.T) {
if code != http.StatusBadRequest {
t.Fatalf("want 400 merging without a git identity, got %d", code)
}
h.do("PUT", "/api/me", api.UserUpdate{GitName: "Alice", GitEmail: "alice@example.org"}, nil)
writeFile(t, filepath.Join(task.WorktreePath, "committed"), "one\ntwo\n")
gitOut(t, task.WorktreePath, "add", "committed")
gitOut(t, task.WorktreePath, "commit", "--message", "wip")
writeFile(t, filepath.Join(task.WorktreePath, "uncommitted"), "three\n")
writeFile(t, filepath.Join(p.RepoPath, "other"), "main moved\n")
gitOut(t, p.RepoPath, "add", "other")
gitOut(t, p.RepoPath, "commit", "--message", "main moved")
moved := gitOut(t, p.RepoPath, "rev-parse", "main")
unmerged := api.Unmerged{}
h.do("GET", "/api/tasks/"+task.Id+"/unmerged", nil, &unmerged)
if unmerged != (api.Unmerged{Commits: 1, Files: 2, Insertions: 3, Deletions: 0, Uncommitted: 1}) {
t.Fatalf("unexpected unmerged %+v", unmerged)
}
merged := api.Task{}
code = h.do("POST", "/api/tasks/"+task.Id+"/merge", nil, &merged)
if code != http.StatusOK || merged.Status != api.TaskStatusMerged || merged.ClosedAt == nil || merged.MergeCommit == nil {
t.Fatalf("merge: %d %+v", code, merged)
}
head := gitOut(t, p.RepoPath, "rev-parse", "main")
if head != *merged.MergeCommit {
t.Fatalf("main at %s, want merge commit %s", head, *merged.MergeCommit)
}
if parent := gitOut(t, p.RepoPath, "rev-parse", "main^"); parent != moved {
t.Fatalf("merge commit parent %s, want %s", parent, moved)
}
if log := gitOut(t, p.RepoPath, "log", "-1", "--format=%s|%an|%cn"); log != "Add notes|Alice|Alice" {
t.Fatalf("unexpected merge commit %s", log)
}
for _, name := range []string{"committed", "uncommitted", "other"} {
_, err := os.Stat(filepath.Join(p.RepoPath, name))
if err != nil {
t.Fatalf("%s not checked out on main: %v", name, err)
}
}
if status := gitOut(t, p.RepoPath, "status", "--porcelain"); status != "" {
t.Fatalf("main checkout dirty after merge: %s", status)
}
if branches := gitOut(t, p.RepoPath, "branch", "--list", task.Branch); branches != "" {
t.Fatalf("branch still exists: %s", branches)
}
}
func TestMergeConflict(t *testing.T) {
h := newHarness(t)
p := h.createProject("demo")
h.do("PUT", "/api/me", api.UserUpdate{GitName: "Alice", GitEmail: "alice@example.org"}, nil)
task := api.Task{}
h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "Edit readme"}, &task)
writeFile(t, filepath.Join(task.WorktreePath, "README"), "task\n")
writeFile(t, filepath.Join(p.RepoPath, "README"), "main\n")
gitOut(t, p.RepoPath, "commit", "--all", "--message", "main edit")
head := gitOut(t, p.RepoPath, "rev-parse", "main")
code := h.do("POST", "/api/tasks/"+task.Id+"/merge", nil, nil)
if code != http.StatusConflict {
t.Fatalf("want 409 on conflict, got %d", code)
}
got := api.Task{}
h.do("GET", "/api/tasks/"+task.Id, nil, &got)
if got.Status != api.TaskStatusActive || gitOut(t, p.RepoPath, "rev-parse", "main") != head {
t.Fatalf("conflicted merge changed state: %+v", got)
}
_, err := os.Stat(task.WorktreePath)
if err != nil {
t.Fatalf("worktree removed after conflict: %v", err)
if got.Status != api.TaskStatusActive {
t.Fatalf("refused merge changed the task: %+v", got)
}
}