fix(workspace-runtime): base fresh worktrees on origin/master and refresh unstarted reuses (#8412)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agent work runs inside git worktrees created by the workspace runtime, each branched from a configured base ref (typically the repo's default branch) > - When the local `master` is stale or ahead of `origin/master` (committed but unpushed work, or local-only commits), a freshly created worktree inherits that divergence — so an unrelated task branch silently carries commits it never intended to touch > - This surfaced as a docs-only task whose PR accidentally pulled in unrelated changes from a diverged local master > - The base for a fresh worktree should be resolved authoritatively to the remote-tracking ref (`origin/<branch>`), and an idle/unstarted reused worktree should be safely fast-forwarded — without ever destroying in-progress work > - This pull request makes both behaviors explicit in the workspace runtime > - The benefit is that task branches start from a clean, authoritative base, eliminating accidental inclusion of unrelated local changes ## Linked Issues or Issue Description This is a bug fix. No public GitHub issue exists, so describing it inline following the Bug Report template: **What happened** A task intended to change only docs produced a PR that also contained unrelated changes pulled in from `master`. The root cause: when a new worktree is created from a configured local branch (e.g. `master`), the worktree inherits whatever that local branch points at. If the local `master` has committed divergence from `origin/master` (unpushed or local-only commits), that divergence leaks into the new task branch. The leak comes from committed local-vs-`origin/master` ref drift, not uncommitted working-tree changes (each worktree has its own working tree). **Expected behavior** A fresh worktree should be based on the authoritative `origin/master` head so unrelated local commits never seed a task branch. **Steps to reproduce** 1. Have a local `master` that is ahead of `origin/master` (committed but unpushed work). 2. Create a new worktree/task branched from `master` via the workspace runtime. 3. Open a PR from that branch — it carries the unrelated local commits. **Deployment mode** Self-hosted / local workspace runtime. ## What Changed - Fresh worktrees now resolve their base ref authoritatively: a configured local branch (e.g. `"master"`) is mapped to its `origin/<branch>` remote-tracking counterpart so unpushed/ahead local commits can never seed a task branch. Remote-tracking refs, SHAs, and tags are used verbatim; an unset/`HEAD` base falls back to the detected default branch. The resolved ref is recorded (`repoRef`) so downstream drift checks stay accurate. - If a configured local branch has no matching `origin/<branch>`, the runtime warns and falls back to the local ref rather than failing. - On reuse, a *provably unstarted* worktree (no commits past base + clean tree including untracked files) is fast-forwarded to the latest `origin/master`. Started or dirty worktrees keep the prior warn-only behavior, so in-progress work is never reset. Only remote-tracking bases are eligible for the refresh. ## Verification - `cd server && npx vitest run src/__tests__/workspace-runtime.test.ts` - 5 new tests cover: local-branch→`origin/<branch>` mapping, no-remote fallback warning, unstarted-reuse fast-forward, and that started/dirty worktrees are left untouched. - Result: 65 passed. 1 pre-existing failure (`auto-detects the default branch via symbolic-ref when origin/HEAD is set`) is unrelated to this change and fails only due to the test host's git default-branch config (test setup runs `git push -u origin main master` but the local default branch is `main`); it also fails on `master`. ## Risks - Low risk. The refresh path is intentionally conservative: it only fast-forwards worktrees that are provably unstarted (zero commits past base and a fully clean tree, including untracked files) and only when the base is a remote-tracking ref. Started or dirty worktrees fall through to the existing warn-only drift behavior, so no in-progress work can be destroyed. - Behavioral shift: fresh worktrees configured against a local branch will now base on `origin/<branch>` instead of the local ref. This is the intended fix; the only case it changes is when local and remote have diverged. ## Model Used Claude — `claude-opus-4-8` (extended thinking, tool use / code execution via Claude Code). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots (N/A — no UI changes) - [x] I have updated relevant documentation to reflect my changes (N/A — no doc changes needed) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green (pending CI run) - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (pending review) - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
@@ -94,6 +94,57 @@ async function createTempRepo(defaultBranch = "main") {
|
|||||||
return repoRoot;
|
return repoRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createClonedRepoWithRemote() {
|
||||||
|
const sourceRepo = await createTempRepo("master");
|
||||||
|
const remoteDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-remote-"));
|
||||||
|
const remotePath = path.join(remoteDir, "paperclip.git");
|
||||||
|
await execFileAsync("git", ["clone", "--bare", sourceRepo, remotePath]);
|
||||||
|
|
||||||
|
const cloneRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-clone-"));
|
||||||
|
const repoRoot = path.join(cloneRoot, "paperclip");
|
||||||
|
await execFileAsync("git", ["clone", remotePath, repoRoot]);
|
||||||
|
await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]);
|
||||||
|
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
|
||||||
|
return { sourceRepo, remotePath, repoRoot };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function advanceRemoteMaster(sourceRepo: string, remotePath: string, fileName: string) {
|
||||||
|
await fs.writeFile(path.join(sourceRepo, fileName), `${fileName}\n`, "utf8");
|
||||||
|
await runGit(sourceRepo, ["add", fileName]);
|
||||||
|
await runGit(sourceRepo, ["commit", "-m", `Add ${fileName}`]);
|
||||||
|
await runGit(sourceRepo, ["push", remotePath, "master"]);
|
||||||
|
return readGit(sourceRepo, ["rev-parse", "master"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function realizeWorktreeForTest(repoRoot: string, repoRef: string | null) {
|
||||||
|
return realizeExecutionWorkspace({
|
||||||
|
base: {
|
||||||
|
baseCwd: repoRoot,
|
||||||
|
source: "project_primary",
|
||||||
|
projectId: "project-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
repoUrl: null,
|
||||||
|
repoRef,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
workspaceStrategy: {
|
||||||
|
type: "git_worktree",
|
||||||
|
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
issue: {
|
||||||
|
id: "issue-1",
|
||||||
|
identifier: "PAP-447",
|
||||||
|
title: "Add Worktree Support",
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
id: "agent-1",
|
||||||
|
name: "Codex Coder",
|
||||||
|
companyId: "company-1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildWorkspace(cwd: string): RealizedExecutionWorkspace {
|
function buildWorkspace(cwd: string): RealizedExecutionWorkspace {
|
||||||
return {
|
return {
|
||||||
baseCwd: cwd,
|
baseCwd: cwd,
|
||||||
@@ -508,6 +559,123 @@ describe("realizeExecutionWorkspace", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("bases a fresh worktree on origin/master even when local master has unpushed commits", async () => {
|
||||||
|
const { repoRoot } = await createClonedRepoWithRemote();
|
||||||
|
const originHead = await readGit(repoRoot, ["rev-parse", "origin/master"]);
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(repoRoot, "unpushed.txt"), "local only\n", "utf8");
|
||||||
|
await runGit(repoRoot, ["add", "unpushed.txt"]);
|
||||||
|
await runGit(repoRoot, ["commit", "-m", "Unpushed local work"]);
|
||||||
|
const localHead = await readGit(repoRoot, ["rev-parse", "master"]);
|
||||||
|
expect(localHead).not.toBe(originHead);
|
||||||
|
|
||||||
|
const workspace = await realizeWorktreeForTest(repoRoot, null);
|
||||||
|
|
||||||
|
expect(workspace.baseRefSha).toBe(originHead);
|
||||||
|
expect(await readGit(workspace.cwd, ["rev-parse", "HEAD"])).toBe(originHead);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a configured local branch base ref to origin/<branch> for fresh worktrees", async () => {
|
||||||
|
const { repoRoot } = await createClonedRepoWithRemote();
|
||||||
|
const originHead = await readGit(repoRoot, ["rev-parse", "origin/master"]);
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(repoRoot, "unpushed.txt"), "local only\n", "utf8");
|
||||||
|
await runGit(repoRoot, ["add", "unpushed.txt"]);
|
||||||
|
await runGit(repoRoot, ["commit", "-m", "Unpushed local work"]);
|
||||||
|
const localHead = await readGit(repoRoot, ["rev-parse", "master"]);
|
||||||
|
expect(localHead).not.toBe(originHead);
|
||||||
|
|
||||||
|
const workspace = await realizeWorktreeForTest(repoRoot, "master");
|
||||||
|
|
||||||
|
expect(workspace.repoRef).toBe("origin/master");
|
||||||
|
expect(workspace.baseRefSha).toBe(originHead);
|
||||||
|
expect(await readGit(workspace.cwd, ["rev-parse", "HEAD"])).toBe(originHead);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fast-forwards an unstarted reused worktree to the advanced origin/master", async () => {
|
||||||
|
const { sourceRepo, remotePath, repoRoot } = await createClonedRepoWithRemote();
|
||||||
|
|
||||||
|
const initial = await realizeWorktreeForTest(repoRoot, null);
|
||||||
|
const initialHead = await readGit(initial.cwd, ["rev-parse", "HEAD"]);
|
||||||
|
|
||||||
|
const advancedHead = await advanceRemoteMaster(sourceRepo, remotePath, "auth-fix.txt");
|
||||||
|
expect(advancedHead).not.toBe(initialHead);
|
||||||
|
|
||||||
|
const reused = await realizeWorktreeForTest(repoRoot, null);
|
||||||
|
|
||||||
|
expect(reused.created).toBe(false);
|
||||||
|
expect(reused.cwd).toBe(initial.cwd);
|
||||||
|
expect(await readGit(reused.cwd, ["rev-parse", "HEAD"])).toBe(advancedHead);
|
||||||
|
expect(reused.baseRefSha).toBe(advancedHead);
|
||||||
|
expect(reused.warnings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reset a reused worktree that already has task commits", async () => {
|
||||||
|
const { sourceRepo, remotePath, repoRoot } = await createClonedRepoWithRemote();
|
||||||
|
|
||||||
|
const initial = await realizeWorktreeForTest(repoRoot, null);
|
||||||
|
await fs.writeFile(path.join(initial.cwd, "task-work.txt"), "in progress\n", "utf8");
|
||||||
|
await runGit(initial.cwd, ["add", "task-work.txt"]);
|
||||||
|
await runGit(initial.cwd, ["commit", "-m", "Task work in progress"]);
|
||||||
|
const taskHead = await readGit(initial.cwd, ["rev-parse", "HEAD"]);
|
||||||
|
|
||||||
|
await advanceRemoteMaster(sourceRepo, remotePath, "auth-fix.txt");
|
||||||
|
|
||||||
|
const reused = await realizeWorktreeForTest(repoRoot, null);
|
||||||
|
|
||||||
|
expect(reused.created).toBe(false);
|
||||||
|
expect(await readGit(reused.cwd, ["rev-parse", "HEAD"])).toBe(taskHead);
|
||||||
|
expect(reused.warnings).toEqual([
|
||||||
|
expect.stringContaining("is behind origin/master by 1 commit"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reset a reused worktree with untracked changes", async () => {
|
||||||
|
const { sourceRepo, remotePath, repoRoot } = await createClonedRepoWithRemote();
|
||||||
|
|
||||||
|
const initial = await realizeWorktreeForTest(repoRoot, null);
|
||||||
|
const initialHead = await readGit(initial.cwd, ["rev-parse", "HEAD"]);
|
||||||
|
await fs.writeFile(path.join(initial.cwd, "scratch.txt"), "uncommitted scratch\n", "utf8");
|
||||||
|
|
||||||
|
await advanceRemoteMaster(sourceRepo, remotePath, "auth-fix.txt");
|
||||||
|
|
||||||
|
const reused = await realizeWorktreeForTest(repoRoot, null);
|
||||||
|
|
||||||
|
expect(reused.created).toBe(false);
|
||||||
|
expect(await readGit(reused.cwd, ["rev-parse", "HEAD"])).toBe(initialHead);
|
||||||
|
await expect(fs.readFile(path.join(reused.cwd, "scratch.txt"), "utf8")).resolves.toBe(
|
||||||
|
"uncommitted scratch\n",
|
||||||
|
);
|
||||||
|
expect(reused.warnings).toEqual([
|
||||||
|
expect.stringContaining("is behind origin/master by 1 commit"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reset a reused worktree with untracked changes when status.showUntrackedFiles=no", async () => {
|
||||||
|
const { sourceRepo, remotePath, repoRoot } = await createClonedRepoWithRemote();
|
||||||
|
|
||||||
|
const initial = await realizeWorktreeForTest(repoRoot, null);
|
||||||
|
const initialHead = await readGit(initial.cwd, ["rev-parse", "HEAD"]);
|
||||||
|
// Without `--untracked-files=all`, this config hides untracked files from
|
||||||
|
// `git status --porcelain`, which would let the clean-tree guard pass and a
|
||||||
|
// `reset --hard` destroy the scratch file below.
|
||||||
|
await readGit(initial.cwd, ["config", "status.showUntrackedFiles", "no"]);
|
||||||
|
await fs.writeFile(path.join(initial.cwd, "scratch.txt"), "uncommitted scratch\n", "utf8");
|
||||||
|
|
||||||
|
await advanceRemoteMaster(sourceRepo, remotePath, "auth-fix.txt");
|
||||||
|
|
||||||
|
const reused = await realizeWorktreeForTest(repoRoot, null);
|
||||||
|
|
||||||
|
expect(reused.created).toBe(false);
|
||||||
|
expect(await readGit(reused.cwd, ["rev-parse", "HEAD"])).toBe(initialHead);
|
||||||
|
await expect(fs.readFile(path.join(reused.cwd, "scratch.txt"), "utf8")).resolves.toBe(
|
||||||
|
"uncommitted scratch\n",
|
||||||
|
);
|
||||||
|
expect(reused.warnings).toEqual([
|
||||||
|
expect.stringContaining("is behind origin/master by 1 commit"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects reusing an empty directory that only looks like a worktree because it sits inside the repo", async () => {
|
it("rejects reusing an empty directory that only looks like a worktree because it sits inside the repo", async () => {
|
||||||
const repoRoot = await createTempRepo();
|
const repoRoot = await createTempRepo();
|
||||||
const branchName = "PAP-447-add-worktree-support";
|
const branchName = "PAP-447-add-worktree-support";
|
||||||
|
|||||||
@@ -628,6 +628,123 @@ export async function inspectExecutionWorkspaceBaseDrift(input: {
|
|||||||
return { warnings, currentBaseRefSha, branchBaseRefSha };
|
return { warnings, currentBaseRefSha, branchBaseRefSha };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function localBranchExists(repoRoot: string, branch: string): Promise<boolean> {
|
||||||
|
return runGit(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], repoRoot)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remoteExists(repoRoot: string, remote: string): Promise<boolean> {
|
||||||
|
return runGit(["remote", "get-url", remote], repoRoot)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the authoritative base ref for a fresh worktree. A configured local
|
||||||
|
// branch is mapped to its `origin/<branch>` counterpart so unpushed local
|
||||||
|
// divergence never leaks into the task branch; remote-tracking refs, SHAs, and
|
||||||
|
// tags are used verbatim, and an unset/`HEAD` base falls back to the detected
|
||||||
|
// default branch (which already prefers `origin/master`).
|
||||||
|
async function resolveAuthoritativeBaseRef(
|
||||||
|
repoRoot: string,
|
||||||
|
configuredBaseRef: string | null,
|
||||||
|
): Promise<{ baseRef: string; warnings: string[]; refreshed: boolean }> {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const detectOrHead = async () => (await detectDefaultBranch(repoRoot)) ?? "HEAD";
|
||||||
|
|
||||||
|
const configured = configuredBaseRef?.trim();
|
||||||
|
if (!configured || configured === "HEAD") {
|
||||||
|
return { baseRef: await detectOrHead(), warnings, refreshed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parseRemoteTrackingRef(configured)) {
|
||||||
|
return { baseRef: configured, warnings, refreshed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await localBranchExists(repoRoot, configured)) {
|
||||||
|
const remoteCandidate = `origin/${configured}`;
|
||||||
|
// Refresh here and keep the warnings; the caller skips its own refresh of
|
||||||
|
// the returned ref (see `refreshed`) so we never fetch the same ref twice.
|
||||||
|
warnings.push(...await refreshRemoteTrackingBaseRef(repoRoot, remoteCandidate));
|
||||||
|
if (await resolveBaseRefSha(repoRoot, remoteCandidate)) {
|
||||||
|
return { baseRef: remoteCandidate, warnings, refreshed: true };
|
||||||
|
}
|
||||||
|
if (await remoteExists(repoRoot, "origin")) {
|
||||||
|
warnings.push(
|
||||||
|
`Configured base ref "${configured}" is a local branch with no matching origin/${configured}; basing the execution workspace on the local ref, which may include unpushed commits.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { baseRef: configured, warnings, refreshed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { baseRef: configured, warnings, refreshed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh a reused worktree to the latest base only when it is provably
|
||||||
|
// unstarted: no task commits past the base and a clean tree (including untracked
|
||||||
|
// files). This pulls an idle worktree forward to the freshest `origin/master`
|
||||||
|
// after a long planning phase without ever destroying in-progress work. Only
|
||||||
|
// remote-tracking bases are eligible; local-only bases keep warn-only drift.
|
||||||
|
async function refreshUnstartedWorktreeToBase(input: {
|
||||||
|
repoRoot: string;
|
||||||
|
worktreePath: string;
|
||||||
|
branchName: string | null;
|
||||||
|
baseRef: string;
|
||||||
|
currentBaseRefSha: string;
|
||||||
|
recorder?: WorkspaceOperationRecorder | null;
|
||||||
|
}): Promise<{ refreshed: boolean; baseRefSha: string | null }> {
|
||||||
|
if (!parseRemoteTrackingRef(input.baseRef)) {
|
||||||
|
return { refreshed: false, baseRefSha: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const headSha = await runGit(["rev-parse", "HEAD"], input.worktreePath).catch(() => null);
|
||||||
|
if (!headSha) {
|
||||||
|
return { refreshed: false, baseRefSha: null };
|
||||||
|
}
|
||||||
|
if (headSha === input.currentBaseRefSha) {
|
||||||
|
return { refreshed: false, baseRefSha: input.currentBaseRefSha };
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitsPastBaseRaw = await runGit(
|
||||||
|
["rev-list", "--count", `${input.currentBaseRefSha}..HEAD`],
|
||||||
|
input.worktreePath,
|
||||||
|
).catch(() => null);
|
||||||
|
const commitsPastBase = commitsPastBaseRaw === null ? null : Number.parseInt(commitsPastBaseRaw, 10);
|
||||||
|
if (commitsPastBase === null || !Number.isFinite(commitsPastBase) || commitsPastBase > 0) {
|
||||||
|
return { refreshed: false, baseRefSha: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force `--untracked-files=all` so untracked files are counted regardless of a
|
||||||
|
// local `status.showUntrackedFiles=no`; otherwise the clean-tree guard could
|
||||||
|
// pass and the `reset --hard` below would destroy untracked work.
|
||||||
|
const status = await runGit(
|
||||||
|
["status", "--porcelain", "--untracked-files=all"],
|
||||||
|
input.worktreePath,
|
||||||
|
).catch(() => null);
|
||||||
|
if (status === null || status.trim().length > 0) {
|
||||||
|
return { refreshed: false, baseRefSha: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordGitOperation(input.recorder, {
|
||||||
|
phase: "worktree_prepare",
|
||||||
|
args: ["reset", "--hard", input.currentBaseRefSha],
|
||||||
|
cwd: input.worktreePath,
|
||||||
|
metadata: {
|
||||||
|
repoRoot: input.repoRoot,
|
||||||
|
worktreePath: input.worktreePath,
|
||||||
|
branchName: input.branchName,
|
||||||
|
baseRef: input.baseRef,
|
||||||
|
previousHeadSha: headSha,
|
||||||
|
baseRefSha: input.currentBaseRefSha,
|
||||||
|
refreshedUnstartedWorktree: true,
|
||||||
|
},
|
||||||
|
successMessage: `Refreshed unstarted git worktree at ${input.worktreePath} to ${input.baseRef} (${formatShortSha(input.currentBaseRefSha)})\n`,
|
||||||
|
failureLabel: `git reset --hard ${input.currentBaseRefSha}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { refreshed: true, baseRefSha: input.currentBaseRefSha };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
type GitWorktreeListEntry = {
|
type GitWorktreeListEntry = {
|
||||||
worktree: string;
|
worktree: string;
|
||||||
@@ -1133,15 +1250,30 @@ export async function realizeExecutionWorkspace(input: {
|
|||||||
const configuredBaseRef = typeof rawStrategy.baseRef === "string" && rawStrategy.baseRef.length > 0
|
const configuredBaseRef = typeof rawStrategy.baseRef === "string" && rawStrategy.baseRef.length > 0
|
||||||
? rawStrategy.baseRef
|
? rawStrategy.baseRef
|
||||||
: input.base.repoRef ?? null;
|
: input.base.repoRef ?? null;
|
||||||
const baseRef = configuredBaseRef
|
const {
|
||||||
?? await detectDefaultBranch(repoRoot)
|
baseRef,
|
||||||
?? "HEAD";
|
warnings: baseRefResolutionWarnings,
|
||||||
const baseRefreshWarnings = await refreshRemoteTrackingBaseRef(repoRoot, baseRef);
|
refreshed: baseRefAlreadyRefreshed,
|
||||||
|
} = await resolveAuthoritativeBaseRef(repoRoot, configuredBaseRef);
|
||||||
|
const baseRefreshWarnings = [
|
||||||
|
...baseRefResolutionWarnings,
|
||||||
|
...(baseRefAlreadyRefreshed ? [] : await refreshRemoteTrackingBaseRef(repoRoot, baseRef)),
|
||||||
|
];
|
||||||
const currentBaseRefSha = await resolveBaseRefSha(repoRoot, baseRef);
|
const currentBaseRefSha = await resolveBaseRefSha(repoRoot, baseRef);
|
||||||
|
|
||||||
await fs.mkdir(worktreeParentDir, { recursive: true });
|
await fs.mkdir(worktreeParentDir, { recursive: true });
|
||||||
|
|
||||||
async function reuseExistingWorktree(reusablePath: string) {
|
async function reuseExistingWorktree(reusablePath: string) {
|
||||||
|
const refresh = currentBaseRefSha
|
||||||
|
? await refreshUnstartedWorktreeToBase({
|
||||||
|
repoRoot,
|
||||||
|
worktreePath: reusablePath,
|
||||||
|
branchName,
|
||||||
|
baseRef,
|
||||||
|
currentBaseRefSha,
|
||||||
|
recorder: input.recorder ?? null,
|
||||||
|
})
|
||||||
|
: { refreshed: false, baseRefSha: null };
|
||||||
const baseDrift = await inspectExecutionWorkspaceBaseDrift({
|
const baseDrift = await inspectExecutionWorkspaceBaseDrift({
|
||||||
repoRoot,
|
repoRoot,
|
||||||
worktreePath: reusablePath,
|
worktreePath: reusablePath,
|
||||||
@@ -1184,13 +1316,14 @@ export async function realizeExecutionWorkspace(input: {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...input.base,
|
...input.base,
|
||||||
|
repoRef: baseRef,
|
||||||
strategy: "git_worktree" as const,
|
strategy: "git_worktree" as const,
|
||||||
cwd: reusablePath,
|
cwd: reusablePath,
|
||||||
branchName,
|
branchName,
|
||||||
worktreePath: reusablePath,
|
worktreePath: reusablePath,
|
||||||
warnings: [...baseRefreshWarnings, ...baseDrift.warnings],
|
warnings: [...baseRefreshWarnings, ...baseDrift.warnings],
|
||||||
created: false,
|
created: false,
|
||||||
baseRefSha: baseDrift.branchBaseRefSha ?? baseDrift.currentBaseRefSha,
|
baseRefSha: refresh.baseRefSha ?? baseDrift.branchBaseRefSha ?? baseDrift.currentBaseRefSha,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1284,6 +1417,7 @@ export async function realizeExecutionWorkspace(input: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...input.base,
|
...input.base,
|
||||||
|
repoRef: baseRef,
|
||||||
strategy: "git_worktree",
|
strategy: "git_worktree",
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
branchName,
|
branchName,
|
||||||
@@ -1342,15 +1476,32 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
|
|||||||
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);
|
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);
|
||||||
const recordedBaseRefSha = readRecordedBaseRefSha(input.workspace.metadata);
|
const recordedBaseRefSha = readRecordedBaseRefSha(input.workspace.metadata);
|
||||||
if (await directoryExists(cwd)) {
|
if (await directoryExists(cwd)) {
|
||||||
|
const reuseBaseRef = input.workspace.baseRef ?? input.base.repoRef ?? null;
|
||||||
|
const reuseWorktreePath = realized.worktreePath ?? cwd;
|
||||||
|
const baseRefreshWarnings = reuseBaseRef
|
||||||
|
? await refreshRemoteTrackingBaseRef(repoRoot, reuseBaseRef)
|
||||||
|
: [];
|
||||||
|
const currentBaseRefSha = reuseBaseRef ? await resolveBaseRefSha(repoRoot, reuseBaseRef) : null;
|
||||||
|
const refresh = reuseBaseRef && currentBaseRefSha
|
||||||
|
? await refreshUnstartedWorktreeToBase({
|
||||||
|
repoRoot,
|
||||||
|
worktreePath: reuseWorktreePath,
|
||||||
|
branchName: realized.branchName,
|
||||||
|
baseRef: reuseBaseRef,
|
||||||
|
currentBaseRefSha,
|
||||||
|
recorder: input.recorder ?? null,
|
||||||
|
})
|
||||||
|
: { refreshed: false, baseRefSha: null };
|
||||||
const baseDrift = await inspectExecutionWorkspaceBaseDrift({
|
const baseDrift = await inspectExecutionWorkspaceBaseDrift({
|
||||||
repoRoot,
|
repoRoot,
|
||||||
worktreePath: realized.worktreePath ?? cwd,
|
worktreePath: reuseWorktreePath,
|
||||||
branchName: realized.branchName,
|
branchName: realized.branchName,
|
||||||
baseRef: input.workspace.baseRef ?? input.base.repoRef ?? null,
|
baseRef: reuseBaseRef,
|
||||||
recordedBaseRefSha,
|
recordedBaseRefSha,
|
||||||
|
skipRefresh: true,
|
||||||
});
|
});
|
||||||
realized.warnings = baseDrift.warnings;
|
realized.warnings = [...baseRefreshWarnings, ...baseDrift.warnings];
|
||||||
realized.baseRefSha = recordedBaseRefSha ?? baseDrift.branchBaseRefSha ?? baseDrift.currentBaseRefSha;
|
realized.baseRefSha = refresh.baseRefSha ?? recordedBaseRefSha ?? baseDrift.branchBaseRefSha ?? baseDrift.currentBaseRefSha;
|
||||||
if (provisionCommand) {
|
if (provisionCommand) {
|
||||||
await provisionExecutionWorktree({
|
await provisionExecutionWorktree({
|
||||||
strategy: {
|
strategy: {
|
||||||
|
|||||||
Reference in New Issue
Block a user