diff --git a/.github/release-state.json b/.github/release-state.json index a97cbe7..718c1aa 100644 --- a/.github/release-state.json +++ b/.github/release-state.json @@ -1,5 +1,5 @@ { - "pullRequest": 9, - "sourceSHA": "a5804b907dc37d653bb9b339300dc3865159ec84", - "version": "0.6.4" + "pullRequest": 11, + "sourceSHA": "4ddfaa21fcaea14090f6110212d9bea40ce0be78", + "version": "0.6.5" } diff --git a/CHANGELOG.md b/CHANGELOG.md index ad90690..6eb1261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ include the full version, for example `## 0.7.0-beta.1`. ## Unreleased +## 0.6.5 + +### Fixed + +- Reuse the saved worktree path when resuming recovered tasks or processing PR + feedback, including after a branch rename. Preserve existing changes and the + pinned base while validating the managed directory, Git root, branch, and + repository before preparation, verification, and publication. A missing saved + worktree blocks recovery instead of creating a replacement. + ## 0.6.4 ### Fixed diff --git a/README.md b/README.md index 36701ef..657a8d5 100644 --- a/README.md +++ b/README.md @@ -26,12 +26,12 @@ Nothing needs to be published to npm. `$HOME` expands to your home directory. Run this command on the machine running OpenCode 2: -Latest stable release: **[v0.6.4](https://github.com/d3cker/opencode2-github-automation/releases/tag/v0.6.4)**. +Latest stable release: **[v0.6.5](https://github.com/d3cker/opencode2-github-automation/releases/tag/v0.6.5)**. -[Download the .tgz package](https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.4/opencode2-automation-0.6.4.tgz) · [SHA-256 checksum](https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.4/opencode2-automation-0.6.4.tgz.sha256) +[Download the .tgz package](https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.5/opencode2-automation-0.6.5.tgz) · [SHA-256 checksum](https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.5/opencode2-automation-0.6.5.tgz.sha256) ```bash -npm install --global --prefix "$HOME/.local" "https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.4/opencode2-automation-0.6.4.tgz" +npm install --global --prefix "$HOME/.local" "https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.5/opencode2-automation-0.6.5.tgz" ``` diff --git a/docs/bot-workflow.md b/docs/bot-workflow.md index d20810e..053d1bc 100644 --- a/docs/bot-workflow.md +++ b/docs/bot-workflow.md @@ -366,6 +366,11 @@ can reconcile a previously lost merge response. Follow-up rounds reset analysis, question, current session, checks, and commit; they retain the branch, worktree, pinned base, and previous session reference. +Preparation reuses the saved worktree path rather than deriving a new path from +the branch name. A renamed branch can therefore retain its original directory. +Preparation, verification, and push all check the managed path, exact Git root, +branch, and shared repository. A missing checkpoint directory blocks the task +without creating a replacement worktree. They create a new main session, whereas an implementation-question reply resumes the current one. Comments received while working stay queued for a later round. Feedback after closure can still be queued, but the next round's guards block it. diff --git a/docs/runtime.md b/docs/runtime.md index 7f5bfe0..f0e13b5 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -180,6 +180,12 @@ acknowledgement, implementation, and a push to the same open PR. The mention doe not need to be repeated. Comments received during execution wait for the next round. A mention in an authorized comment can also start work on an untracked issue. +Follow-up rounds reuse the worktree path saved in the queue, even if recovery +renamed its branch. Preparation, verification, and push validate that path as a +worktree root directly inside the managed worktree directory, attached to the +expected branch and repository. If the saved directory is missing, restore it +before retrying; the bot does not create a replacement or discard existing work. + Edits to existing comments and PR review comments are not supported. Closing the issue or closing/merging the PR blocks further rounds. diff --git a/package-lock.json b/package-lock.json index 70b740c..5e5dcd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode2-automation", - "version": "0.6.4", + "version": "0.6.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode2-automation", - "version": "0.6.4", + "version": "0.6.5", "hasInstallScript": true, "dependencies": { "@opencode/client": "0.0.0-beta-19398", diff --git a/package.json b/package.json index c078a43..6b05249 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode2-automation", - "version": "0.6.4", + "version": "0.6.5", "description": "Issue-to-PR automation for OpenCode 2 with a scheduler and GitHub dispatcher", "main": "./dist/index.js", "files": [ diff --git a/src/executor.ts b/src/executor.ts index f264e04..def8bf1 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -1,7 +1,7 @@ import type { Plugin } from "@opencode/plugin"; import { execFile } from "node:child_process"; import { mkdir, realpath, stat } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { randomUUID, createHash } from "node:crypto"; import type { GithubOptions, Repository } from "./config.js"; import { botPrompt } from "./prompt.js"; @@ -39,7 +39,9 @@ export class GitWorkspace { } async prepare(task: Task, repo: Repository) { await this.validate(repo); - const directory = join(this.stateDirectory, "worktrees", task.branch.replaceAll("/", "-")); + // A recovered task can have a renamed branch while retaining its original + // worktree. The checkpoint, not the current branch spelling, owns its path. + const directory = task.worktree ?? join(this.stateDirectory, "worktrees", task.branch.replaceAll("/", "-")); await mkdir(join(this.stateDirectory, "worktrees"), { recursive: true }); // Existing worktrees are reused only after checking their exact branch and shared repository. if (await stat(directory).then(() => true, e => { if (e.code === "ENOENT") return false; throw e; })) { @@ -47,6 +49,7 @@ export class GitWorkspace { const baseSha = task.baseSha ?? await this.git(directory, "merge-base", "HEAD", `refs/remotes/origin/${repo.baseBranch}`); return { worktree: await realpath(directory), baseSha }; } + if (task.worktree) throw new Blocked("Saved task worktree is missing; restore it before retrying"); try { await this.git(repo.directory, "fetch", "origin", `refs/heads/${repo.baseBranch}:refs/remotes/origin/${repo.baseBranch}`); } catch (cause) { throw new Error(`Could not fetch base branch ${repo.baseBranch} from origin; check that it exists and Git authentication works`, { cause }); } const baseSha = await this.git(repo.directory, "rev-parse", `refs/remotes/origin/${repo.baseBranch}`); @@ -62,8 +65,11 @@ export class GitWorkspace { return result.split(/\r?\n/).some(line => line.split(/\s+/)[1] === ref); } private async assertWorktree(directory: string, task: Task, repo: Repository) { - const expected = join(await realpath(this.stateDirectory), "worktrees", task.branch.replaceAll("/", "-")); - if (await realpath(directory) !== resolve(expected)) throw new Blocked("Unexpected worktree path"); + const managed = join(await realpath(this.stateDirectory), "worktrees"); + const expected = task.worktree ? resolve(task.worktree) : join(managed, task.branch.replaceAll("/", "-")); + const actual = await realpath(directory); + if (actual !== expected || dirname(actual) !== managed) throw new Blocked("Unexpected worktree path"); + if (await realpath(await this.git(directory, "rev-parse", "--show-toplevel")) !== actual) throw new Blocked("Task directory is not the worktree root"); if (await this.git(directory, "branch", "--show-current") !== task.branch) throw new Blocked("Worktree branch changed"); const common = await this.git(directory, "rev-parse", "--path-format=absolute", "--git-common-dir"); const original = await this.git(repo.directory, "rev-parse", "--path-format=absolute", "--git-common-dir"); diff --git a/test/executor.test.ts b/test/executor.test.ts index f0bb269..83181ad 100644 --- a/test/executor.test.ts +++ b/test/executor.test.ts @@ -107,7 +107,7 @@ test("real git worktree isolates a fix, verifies, commits and pushes to a local assert.equal(await git.hasBranch(repo, "release/next"), true); assert.equal(await git.hasBranch(repo, "release"), false); assert.equal(await git.hasBranch(repo, "missing"), false); - const t = task(); Object.assign(t, await git.prepare(t, repo)); + const t: Task = { ...task(), worktree: undefined }; Object.assign(t, await git.prepare(t, repo)); assert.equal(t.baseSha, selectedBase); assert.match(await readFile(join(t.worktree!, "release.txt"), "utf8"), /release-only/); await installWorkerPlugin(t.worktree!, options, run); @@ -124,10 +124,30 @@ test("real git worktree isolates a fix, verifies, commits and pushes to a local const withoutTests = await git.verify(t, { ...repo, checks: [] }); assert.deepEqual(withoutTests.checks, []); assert.equal(withoutTests.commit, t.commit); + // Recovery can rename a published branch without moving its worktree. + // Follow-up prepare, verification, and push must all honor the checkpoint. + const savedPath = t.worktree!, savedBase = t.baseSha; + await run(savedPath, ["git", "branch", "-m", "recovered-feature"]); + t.branch = "recovered-feature"; + await writeFile(join(savedPath, "followup.txt"), "preserve this uncommitted work\n"); + Object.assign(t, await git.prepare(t, repo)); + assert.equal(t.worktree, savedPath); + assert.equal(t.baseSha, savedBase); + assert.equal(await readFile(join(savedPath, "followup.txt"), "utf8"), "preserve this uncommitted work\n"); + Object.assign(t, await git.verify(t, repo)); + await git.push(t, repo); + assert.equal(await run(dir, ["git", "--git-dir", remote, "rev-parse", "refs/heads/recovered-feature"]), t.commit); + await assert.rejects(git.prepare({ ...t, worktree: join(state, "worktrees", "missing-checkpoint") }, repo), /Saved task worktree is missing/); + await assert.rejects(git.prepare({ ...t, worktree: checkout }, repo), /Unexpected worktree path/); + await assert.rejects(git.prepare({ ...t, branch: "wrong-branch" }, repo), /Worktree branch changed/); + const foreign = join(savedPath, "..", "foreign"); + await mkdir(foreign); + await run(foreign, ["git", "init", "-b", t.branch]); + await assert.rejects(git.prepare({ ...t, worktree: foreign }, repo), /another repository/); assert.equal(await run(t.worktree!, ["git", "ls-files", "--", ".opencode/plugins/automation-runtime/index.js"]), ""); await writeFile(runtimePath, "// User customization\n"); await assert.rejects(installWorkerPlugin(t.worktree!, options, run), /customized/); - await assert.rejects(git.prepare({ ...task(), branch: "automation/missing-base" }, { ...repo, baseBranch: "missing" }), /Could not fetch base branch missing/); + await assert.rejects(git.prepare({ ...task(), worktree: undefined, branch: "automation/missing-base" }, { ...repo, baseBranch: "missing" }), /Could not fetch base branch missing/); await writeFile(join(t.worktree!, "counter.txt"), "changed after verification\n"); await assert.rejects(git.push(t, repo), /changed after verification/); } finally { await rm(dir, { recursive: true, force: true }); }