feat(routing): cross-repo task routing + orphan-branch cleanup (#17)

Two related fixes for the agent-workflow defects exposed by the
2-May-2026 batch:

1. **Cross-repo task routing** (`task.repo_url` override).
   `resolveRepoRoot` now consults `task.repo_url` first; matches against
   per-repo env-var (`SCRUM4ME_REPO_ROOT_REPO_<name>`),
   `~/.scrum4me-agent-config.json` `repoRoots[<name>]`, and finally
   `~/Projects/<name>/.git`. Falls back to product-level resolution
   when null. Tasks tracked under one product but targeting another
   repo (e.g. MCP-server tasks under the main product's PBI) now work.
   `getFullJobContext` exposes `task.repo_url` to the agent.
   `attachWorktreeToJob` accepts and forwards it.

2. **Orphan-branch cleanup** in `createWorktreeForJob`.
   Previously a name-collision suffixed with a timestamp, leaving the
   agent on an unpredictable `feat/story-XXX-<ms>`-name. Worse, in the
   2-May batch the agent ended up reusing an orphan branch from an
   earlier story (`feat/story-x35n155c`) and pushed to a remote ref
   that did not exist, causing 'src refspec does not match any'.

   Now: detect orphan, attempt to remove its (stale) worktree if any,
   delete the local branch, and recreate with the predictable name.
   Timestamp-suffix is the last resort.

Vendor submodule bumped to pick up `Task.repo_url` from Scrum4Me #54.

Tests: 129/129 — `suffixes branch name with timestamp` updated to
`removes orphan branch and reuses the predictable name`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-02 18:07:57 +02:00 committed by GitHub
parent 1fe6ccf609
commit 2c85f4d239
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 88 additions and 16 deletions

View file

@ -74,11 +74,12 @@ describe('createWorktreeForJob', () => {
expect(result.worktreePath).toBe(path.join(wtParent, 'job-001'))
})
it('suffixes branch name with timestamp when branch already exists', async () => {
it('removes orphan branch and reuses the predictable name when no worktree owns it', async () => {
const { repoDir, originDir } = await setupRepo()
tmpDirs.push(repoDir, originDir)
await makeWorktreeParent()
// Pre-create an orphan branch (no worktree attached)
await git(['branch', 'feat/job-002'], repoDir)
const result = await createWorktreeForJob({
@ -88,10 +89,11 @@ describe('createWorktreeForJob', () => {
baseRef: 'origin/main',
})
expect(result.branchName).toMatch(/^feat\/job-002-\d+$/)
// Orphan was deleted → predictable name reused, no timestamp suffix
expect(result.branchName).toBe('feat/job-002')
const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath)
expect(stdout.trim()).toBe(result.branchName)
expect(stdout.trim()).toBe('feat/job-002')
})
it('rejects when worktree path already exists', async () => {