Skip to content

Commit eec8a5e

Browse files
authored
fix(worktree): resolve the repo's real remote+default branch, not hardcoded origin/main (INT-2545) (#271)
The daemon could not do code work on entire repos. createWorktree() + commitAndCreatePR() hardcoded 'origin/main' everywhere, so any repo that doesn't match BOTH assumptions failed at the very first step: - default branch is 'master' (pykiwoom-rest, ArtifactNet) → 'git worktree add … origin/main' died with 'fatal: invalid reference: origin/main' → worktree creation failed → the issue could NEVER be worked (observed live: the dominant daemon error, every STO-* task stuck); - remote isn't named 'origin' (vega-agent uses 'unohee') → same failure. resolveBaseRef(gitDir) now resolves {remote, branch, ref}: prefer <remote>/HEAD, fall back to main then master; pick 'origin' if present else the first remote. Wired through the whole create→commits-ahead→push→PR chain and the overlap report, so master-default and non-origin repos work end to end (create from the real base, push to the real remote, PR against the real base branch). Tests: resolveBaseRef over origin/main, origin/master, and a non-origin remote; createWorktree succeeds on a master-default repo and a non-origin repo; and commitAndCreatePR (fake gh on PATH) pushes to the resolved remote and PRs against the resolved base. tsc + oxlint clean, full suite 148 files / 1760 passed; openswarm review APPROVE.
1 parent 0fcf46b commit eec8a5e

2 files changed

Lines changed: 155 additions & 20 deletions

File tree

src/support/worktreeManager.test.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync
33
import { tmpdir } from 'node:os';
44
import { join, resolve } from 'node:path';
55
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6-
import { createWorktree, preserveWorktree, removePreservedWorktreeAt, removeWorktree, resolveSharedPaths, computeFileOverlaps, formatOverlapReport, type WorktreeInfo } from './worktreeManager.js';
6+
import { createWorktree, preserveWorktree, removePreservedWorktreeAt, removeWorktree, resolveSharedPaths, computeFileOverlaps, formatOverlapReport, resolveBaseRef, commitAndCreatePR, type WorktreeInfo } from './worktreeManager.js';
77

88
describe('worktreeManager path safety', () => {
99
let root: string;
@@ -348,3 +348,99 @@ describe('removePreservedWorktreeAt (INT-2506)', () => {
348348
expect(existsSync(repo)).toBe(true);
349349
});
350350
});
351+
352+
describe('resolveBaseRef / createWorktree on non-main-default repos (INT-2545)', () => {
353+
let root: string;
354+
const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-C', cwd, ...args], { stdio: 'pipe' });
355+
356+
// Build a repo whose origin default branch is `defaultBranch`, pushed to a bare
357+
// remote named `remoteName`. Returns the repo path.
358+
function makeRepo(name: string, defaultBranch: string, remoteName: string): string {
359+
const repo = join(root, name);
360+
const bare = join(root, `${name}.git`);
361+
mkdirSync(repo, { recursive: true });
362+
execFileSync('git', ['init', '--bare', '-b', defaultBranch, bare], { stdio: 'pipe' });
363+
execFileSync('git', ['init', '-b', defaultBranch, repo], { stdio: 'pipe' });
364+
git(repo, 'config', 'user.email', 'test@example.com');
365+
git(repo, 'config', 'user.name', 'Test');
366+
git(repo, 'config', 'commit.gpgsign', 'false');
367+
writeFileSync(join(repo, 'app.py'), 'base\n');
368+
git(repo, 'add', '-A');
369+
git(repo, 'commit', '-m', 'init');
370+
git(repo, 'remote', 'add', remoteName, bare);
371+
git(repo, 'push', remoteName, defaultBranch);
372+
// Set <remote>/HEAD so symbolic-ref resolves (mirrors a normal clone).
373+
git(repo, 'remote', 'set-head', remoteName, defaultBranch);
374+
return repo;
375+
}
376+
377+
beforeEach(() => {
378+
root = join(tmpdir(), `openswarm-baseref-${process.pid}-${Date.now()}`);
379+
mkdirSync(root, { recursive: true });
380+
});
381+
afterEach(() => rmSync(root, { recursive: true, force: true }));
382+
383+
it('resolves origin/main, origin/master, and a non-origin remote', async () => {
384+
const mainRepo = makeRepo('mainrepo', 'main', 'origin');
385+
expect(await resolveBaseRef(mainRepo)).toEqual({ remote: 'origin', branch: 'main', ref: 'origin/main' });
386+
387+
const masterRepo = makeRepo('masterrepo', 'master', 'origin');
388+
expect(await resolveBaseRef(masterRepo)).toEqual({ remote: 'origin', branch: 'master', ref: 'origin/master' });
389+
390+
const forkRepo = makeRepo('forkrepo', 'main', 'unohee'); // remote not named origin (vega-agent case)
391+
expect(await resolveBaseRef(forkRepo)).toEqual({ remote: 'unohee', branch: 'main', ref: 'unohee/main' });
392+
});
393+
394+
it('createWorktree succeeds on a master-default repo (was: fatal invalid reference origin/main)', async () => {
395+
const repo = makeRepo('masterrepo', 'master', 'origin');
396+
const info = await createWorktree(repo, 'INT-9', 'swarm/INT-9-test');
397+
expect(existsSync(info.worktreePath)).toBe(true);
398+
// Branched from origin/master — app.py from the base commit is present.
399+
expect(existsSync(join(info.worktreePath, 'app.py'))).toBe(true);
400+
expect(execFileSync('git', ['-C', info.worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' }).trim())
401+
.toBe('swarm/INT-9-test');
402+
git(repo, 'worktree', 'remove', '--force', info.worktreePath);
403+
});
404+
405+
it('createWorktree succeeds on a repo whose remote is not named origin', async () => {
406+
const repo = makeRepo('forkrepo', 'main', 'unohee');
407+
const info = await createWorktree(repo, 'INT-9', 'swarm/INT-9-test');
408+
expect(existsSync(info.worktreePath)).toBe(true);
409+
expect(existsSync(join(info.worktreePath, 'app.py'))).toBe(true);
410+
git(repo, 'worktree', 'remove', '--force', info.worktreePath);
411+
});
412+
413+
it('commitAndCreatePR pushes to the RESOLVED remote and PRs against the resolved base (non-origin)', async () => {
414+
const repo = makeRepo('forkrepo', 'main', 'unohee');
415+
const bare = join(root, 'forkrepo.git');
416+
const info = await createWorktree(repo, 'INT-9', 'swarm/INT-9-test');
417+
writeFileSync(join(info.worktreePath, 'feature.py'), 'new work\n'); // a change to commit + PR
418+
419+
// Fake `gh` on PATH: record its args, return a non-/pull/ URL so registerOwnedPR
420+
// (which parses github.com/owner/repo#/pull/N) is skipped — no state written.
421+
const bin = join(root, 'bin');
422+
mkdirSync(bin, { recursive: true });
423+
const ghLog = join(root, 'gh-args.log');
424+
writeFileSync(join(bin, 'gh'),
425+
`#!/bin/sh\nprintf '%s\\n' "$*" >> "${ghLog}"\ncase "$*" in *"pr create"*) echo "https://example.test/created";; esac\n`);
426+
chmodSync(join(bin, 'gh'), 0o755);
427+
428+
const prevPath = process.env.PATH;
429+
process.env.PATH = `${bin}:${prevPath}`;
430+
try {
431+
const url = await commitAndCreatePR(info, 'test title', 'INT-9', 'desc');
432+
expect(url).toBe('https://example.test/created');
433+
} finally {
434+
process.env.PATH = prevPath;
435+
}
436+
437+
// The push landed on the NON-origin remote (had base ref stayed origin/main, the
438+
// commits-ahead count would be 0 and it would have bailed BEFORE pushing).
439+
expect(execFileSync('git', ['-C', bare, 'branch', '--list', 'swarm/INT-9-test'], { encoding: 'utf8' }))
440+
.toContain('swarm/INT-9-test');
441+
// gh pr create used the resolved base branch, not a hardcoded 'main'-that-happens-to-match.
442+
expect(readFileSync(ghLog, 'utf8')).toMatch(/pr create .*--base main/);
443+
444+
git(repo, 'worktree', 'remove', '--force', info.worktreePath);
445+
});
446+
});

src/support/worktreeManager.ts

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,38 @@ async function gh(cwd: string, ...args: string[]): Promise<string> {
3838
return stdout;
3939
}
4040

41+
/**
42+
* Resolve the base remote + default branch to branch worktrees and PRs from.
43+
* OpenSwarm hardcoded `origin/main` everywhere, which silently broke every repo that
44+
* doesn't match BOTH assumptions: a repo whose default branch is `master`
45+
* (pykiwoom-rest, ArtifactNet) died at `git worktree add … origin/main` with
46+
* `fatal: invalid reference: origin/main` → creation failed → the issue could NEVER be
47+
* worked; a repo whose remote isn't named `origin` (vega-agent uses `unohee`) failed
48+
* the same way. Prefer the remote's own HEAD (`<remote>/HEAD`), fall back to main then
49+
* master. Works from a repo path or a worktree path (both share the repo's refs).
50+
* (INT-2545)
51+
*/
52+
export async function resolveBaseRef(gitDir: string): Promise<{ remote: string; branch: string; ref: string }> {
53+
const remotes = (await git(gitDir, 'remote').catch(() => ''))
54+
.split('\n').map((s) => s.trim()).filter(Boolean);
55+
const remote = remotes.includes('origin') ? 'origin' : (remotes[0] || 'origin');
56+
let branch = '';
57+
try {
58+
const head = (await git(gitDir, 'symbolic-ref', `refs/remotes/${remote}/HEAD`)).trim();
59+
branch = head.replace(`refs/remotes/${remote}/`, '');
60+
} catch { /* <remote>/HEAD not set locally — fall through to probing */ }
61+
if (!branch) {
62+
for (const cand of ['main', 'master']) {
63+
if (await git(gitDir, 'rev-parse', '--verify', `${remote}/${cand}`).then(() => true).catch(() => false)) {
64+
branch = cand;
65+
break;
66+
}
67+
}
68+
}
69+
if (!branch) branch = 'main'; // last resort — a bare repo with no fetched default
70+
return { remote, branch, ref: `${remote}/${branch}` };
71+
}
72+
4173
// Types
4274

4375
export interface WorktreeInfo {
@@ -312,14 +344,17 @@ export async function createWorktree(
312344
);
313345
}
314346

315-
// Update main to latest
316-
await git(repoPath, 'fetch', 'origin', 'main').catch((e) =>
317-
console.warn(`[Worktree] Failed to fetch origin/main:`, e)
347+
// Resolve the repo's real base ref (remote + default branch) — NOT hardcoded
348+
// origin/main, which fataled on master-default / non-origin repos and blocked every
349+
// task in them. (INT-2545)
350+
const base = await resolveBaseRef(repoPath);
351+
await git(repoPath, 'fetch', base.remote, base.branch).catch((e) =>
352+
console.warn(`[Worktree] Failed to fetch ${base.ref}:`, e)
318353
);
319354

320-
// Create fresh worktree from origin/main
321-
await git(repoPath, 'worktree', 'add', '-b', branchName, worktreePath, 'origin/main');
322-
console.log(`[Worktree] Created: ${worktreePath} (branch: ${branchName})`);
355+
// Create fresh worktree from the resolved base ref
356+
await git(repoPath, 'worktree', 'add', '-b', branchName, worktreePath, base.ref);
357+
console.log(`[Worktree] Created: ${worktreePath} (branch: ${branchName}, base: ${base.ref})`);
323358

324359
// Share the original repo's gitignored deps/data into the fresh worktree so the
325360
// worker can actually install / run tests / verify against real data. (INT-2415)
@@ -405,13 +440,14 @@ async function collectActiveScopes(worktreePath: string, selfBranch: string): Pr
405440

406441
// Active swarm/* branches without their own PR yet (exclude self + PR'd branches).
407442
try {
408-
const branches = toLines(await git(worktreePath, 'branch', '-r', '--list', 'origin/swarm/*'))
409-
.map(b => b.replace(/^origin\//, ''));
443+
const base = await resolveBaseRef(worktreePath);
444+
const branches = toLines(await git(worktreePath, 'branch', '-r', '--list', `${base.remote}/swarm/*`))
445+
.map(b => b.replace(new RegExp(`^${base.remote}/`), ''));
410446
for (const b of branches) {
411447
if (b === selfBranch || prBranches.has(b)) continue;
412448
try {
413-
const files = toLines(await git(worktreePath, 'diff', '--name-only', `origin/main...origin/${b}`));
414-
if (files.length) scopes.push({ label: `branch origin/${b}`, files });
449+
const files = toLines(await git(worktreePath, 'diff', '--name-only', `${base.ref}...${base.remote}/${b}`));
450+
if (files.length) scopes.push({ label: `branch ${base.remote}/${b}`, files });
415451
} catch { /* skip this branch */ }
416452
}
417453
} catch { /* git unavailable — skip branch scopes */ }
@@ -425,7 +461,8 @@ async function collectActiveScopes(worktreePath: string, selfBranch: string): Pr
425461
*/
426462
async function buildFileOverlapSection(worktreePath: string, selfBranch: string): Promise<string> {
427463
try {
428-
const selfFiles = toLines(await git(worktreePath, 'diff', '--name-only', 'origin/main...HEAD'));
464+
const base = await resolveBaseRef(worktreePath);
465+
const selfFiles = toLines(await git(worktreePath, 'diff', '--name-only', `${base.ref}...HEAD`));
429466
if (selfFiles.length === 0) return '';
430467
const others = await collectActiveScopes(worktreePath, selfBranch);
431468
return formatOverlapReport(computeFileOverlaps(selfFiles, others));
@@ -463,20 +500,22 @@ export async function commitAndCreatePR(
463500
console.log(`[Worktree] Committed uncommitted changes (${branchName})`);
464501
}
465502

466-
// Check if there are any commits ahead of origin/main (including worker-made commits)
467-
const commitsAhead = await git(worktreePath, 'rev-list', '--count', 'origin/main..HEAD')
503+
// Check if there are any commits ahead of the base ref (including worker-made
504+
// commits) — resolved, not hardcoded origin/main (INT-2545).
505+
const base = await resolveBaseRef(worktreePath);
506+
const commitsAhead = await git(worktreePath, 'rev-list', '--count', `${base.ref}..HEAD`)
468507
.then((out) => parseInt(out.trim(), 10))
469508
.catch(() => 0);
470509

471510
if (commitsAhead === 0) {
472-
console.log(`[Worktree] No commits ahead of origin/main (${branchName}) - nothing to PR`);
473-
throw new Error('No commits to create PR from - branch has no changes compared to main');
511+
console.log(`[Worktree] No commits ahead of ${base.ref} (${branchName}) - nothing to PR`);
512+
throw new Error(`No commits to create PR from - branch has no changes compared to ${base.branch}`);
474513
}
475514

476-
console.log(`[Worktree] Branch ${branchName} has ${commitsAhead} commit(s) ahead of origin/main`);
515+
console.log(`[Worktree] Branch ${branchName} has ${commitsAhead} commit(s) ahead of ${base.ref}`);
477516

478-
// Push branch to remote (always push since we have commits ahead)
479-
await git(worktreePath, 'push', '-u', 'origin', branchName, '--force-with-lease');
517+
// Push branch to the resolved remote (always push since we have commits ahead)
518+
await git(worktreePath, 'push', '-u', base.remote, branchName, '--force-with-lease');
480519
console.log(`[Worktree] Pushed branch ${branchName}`);
481520

482521
// If PR already exists, just return the URL
@@ -504,7 +543,7 @@ export async function commitAndCreatePR(
504543
'🤖 Generated with [OpenSwarm](https://github.com/Intrect-io/OpenSwarm)',
505544
].join('\n');
506545

507-
const prUrl = await gh(worktreePath, 'pr', 'create', '--head', branchName, '--base', 'main', '--title', title, '--body', prBody);
546+
const prUrl = await gh(worktreePath, 'pr', 'create', '--head', branchName, '--base', base.branch, '--title', title, '--body', prBody);
508547

509548
const url = prUrl.trim();
510549
console.log(`[Worktree] PR created: ${url}`);

0 commit comments

Comments
 (0)