diff --git a/server/src/modules/computers/adapters/boat.test.ts b/server/src/modules/computers/adapters/boat.test.ts index cdd2ccda04..a31ac6978f 100644 --- a/server/src/modules/computers/adapters/boat.test.ts +++ b/server/src/modules/computers/adapters/boat.test.ts @@ -840,6 +840,61 @@ process.stdout.write(result.stdout||"");process.stderr.write(result.stderr||""); expect(existsSync(join(f.root, "tasks", "missing"))).toBe(false); expect(spawnSync("git", ["show-ref", "--verify", "refs/heads/paperclip/missing"], { cwd: checkout }).status).not.toBe(0); }); + it.each(["unpublished", "feature/unpublished"])("uses a verified unpushed local base only after proving its remote absence: %s", branch => { + const f = fixture(), source = join(f.temp, "source"); mkdirSync(source); + const git = (cwd: string, ...args: string[]) => { + const result = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (result.status !== 0) throw new Error(result.stderr); + return result.stdout.trim(); + }; + const commit = (cwd: string, message: string) => git(cwd, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", message); + git(source, "init", "-b", "main"); commit(source, "default"); + const repositoryUrl = "https://fixture.invalid/local-base.git"; + const gitAuth = { configArgs: ["-c", `url.file://${source}.insteadOf=${repositoryUrl}`, "-c", "protocol.file.allow=always"], env: {} }; + const shared = { action: "workspace", mode: "shared", repositoryUrl, gitAuth }; + const checkout = f.call(shared).remoteCwd; + git(checkout, "checkout", "-b", branch); commit(checkout, "unpushed agent work"); + const localCommit = git(checkout, "rev-parse", "HEAD"); + writeFileSync(join(checkout, "uncommitted"), "keep"); + const request = { ...shared, mode: "worktree", baseRef: branch, taskId: "local-base", branch: "paperclip/local-base" }; + const result = f.call(request); + expect(result.remoteCwd).toBe(join(f.root, "tasks", "local-base")); + expect(git(result.remoteCwd, "rev-parse", "HEAD")).toBe(localCommit); + const deniedAuth = { ...gitAuth, configArgs: [...gitAuth.configArgs, "-c", "protocol.file.allow=never"] }; + expect(f.call({ ...request, gitAuth: deniedAuth, taskId: "unknown-remote", branch: "paperclip/unknown-remote" })).toEqual({ error: "command_failed" }); + expect(existsSync(join(f.root, "tasks", "unknown-remote"))).toBe(false); + for (const baseRef of [`origin/${branch}`, `refs/remotes/origin/${branch}`]) { + expect(f.call({ ...request, baseRef, taskId: "remote-only", branch: "paperclip/remote-only" })).toEqual({ error: "command_failed" }); + } + // Explicit local-ref intent does not depend on the remote's availability. + const explicit = f.call({ ...request, gitAuth: deniedAuth, baseRef: `refs/heads/${branch}`, taskId: "explicit-local", branch: "paperclip/explicit-local" }); + expect(git(explicit.remoteCwd, "rev-parse", "HEAD")).toBe(localCommit); + git(source, "checkout", "-b", branch); commit(source, "published remote work"); + const remoteCommit = git(source, "rev-parse", "HEAD"); + const remote = f.call({ ...request, taskId: "remote-base", branch: "paperclip/remote-base" }); + expect(git(remote.remoteCwd, "rev-parse", "HEAD")).toBe(remoteCommit); + const bin = join(f.temp, "bin"); mkdirSync(bin); + const realGit = spawnSync("which", ["git"], { encoding: "utf8" }).stdout.trim(); + const probe = join(f.temp, "remote-probe"), fetch = join(f.temp, "fetch-attempt"); + writeFileSync(join(bin, "git"), `#!${process.execPath} +const fs=require("node:fs"),{spawnSync}=require("node:child_process"),args=process.argv.slice(2); +if(args.includes("fetch")){fs.writeFileSync(process.env.FIXTURE_FETCH,"attempted");process.exit(128);} +const result=spawnSync(process.env.FIXTURE_GIT,args,{encoding:"utf8"}); +if(args.includes("ls-remote")&&result.status===0)fs.writeFileSync(process.env.FIXTURE_PROBE,"present"); +process.stdout.write(result.stdout||"");process.stderr.write(result.stderr||"");process.exit(result.status??1); +`, { mode: 0o700 }); + expect(f.call({ ...request, taskId: "failed-refresh", branch: "paperclip/failed-refresh" }, { + ...process.env, PATH: `${bin}:${process.env.PATH}`, FIXTURE_GIT: realGit, FIXTURE_PROBE: probe, FIXTURE_FETCH: fetch, + })).toEqual({ error: "command_failed" }); + expect(readFileSync(probe, "utf8")).toBe("present"); + expect(readFileSync(fetch, "utf8")).toBe("attempted"); + expect(existsSync(join(f.root, "tasks", "failed-refresh"))).toBe(false); + + const explicitAfter = f.call({ ...request, baseRef: `refs/heads/${branch}`, taskId: "explicit-after", branch: "paperclip/explicit-after" }); + expect(git(explicitAfter.remoteCwd, "rev-parse", "HEAD")).toBe(localCommit); + expect(readFileSync(join(checkout, "uncommitted"), "utf8")).toBe("keep"); + expect(git(checkout, "rev-parse", `refs/heads/${branch}`)).toBe(localCommit); + }); it.each([false, true])("attaches only the exact existing worktree branch (present: %s)", present => { const f = fixture(), checkout = join(f.root, "checkout"); mkdirSync(checkout, { recursive: true }); diff --git a/server/src/modules/computers/adapters/remote-program.ts b/server/src/modules/computers/adapters/remote-program.ts index 520a704363..9485481376 100644 --- a/server/src/modules/computers/adapters/remote-program.ts +++ b/server/src/modules/computers/adapters/remote-program.ts @@ -93,6 +93,7 @@ if act=='workspace': return result.stdout.strip() if result.returncode==0 else None def worktree_base(value): if not value or value=='HEAD':return 'HEAD' + if value.startswith('refs/heads/'):return commit_ref(value) or fail('command_failed') remotes=run(['git','remote'],checkout).splitlines() remote=None;remote_branch=None candidate=value[len('refs/remotes/'):] if value.startswith('refs/remotes/') else value @@ -102,12 +103,18 @@ if act=='workspace': else: local=value[len('refs/heads/'):] if value.startswith('refs/heads/') else value local_branch=subprocess.run(['git','show-ref','--verify','--quiet','refs/heads/'+local],cwd=checkout,capture_output=True).returncode==0 - # Tags and commit expressions retain their exact meaning. A local branch - # with origin uses the remote counterpart rather than unpushed divergence. + # Tags and commit expressions retain their exact meaning. Bare branches + # prefer origin; only a successful absence check permits a local-only base. if not local_branch: resolved=commit_ref(value) if resolved:return resolved - if 'origin' in remotes:remote,remote_branch='origin',local + if 'origin' in remotes: + if local_branch: + local_commit=commit_ref('refs/heads/'+local) + present=subprocess.run(['git']+auth.get('configArgs',[])+['ls-remote','--exit-code','--heads','--','origin','refs/heads/'+local],cwd=checkout,env=git_env,capture_output=True,text=True) + if present.returncode==2:return local_commit or fail('command_failed') + if present.returncode:fail('command_failed') + remote,remote_branch='origin',local else:return commit_ref(value) or fail('command_failed') if remote_branch=='HEAD':return commit_ref(value) or fail('command_failed') if subprocess.run(['git','check-ref-format','refs/heads/'+remote_branch],cwd=checkout,capture_output=True).returncode: