Fix/local branch worktree base (#2328)
* Add automatic plus branch maintenance workflow * Build plus artifacts for desktop and CLI/server * Document and summarize plus artifact workflow * Document Apple signing and pin plus workflow actions * Disable Linux plus desktop artifact * Publish plus CLI packages to GitHub Packages * Fix GitHub Packages publish paths * Fix plus mise install summary package name * Disable macOS x64 plus desktop artifact * Tag plus npm packages as latest for mise * fix(worktrees): respect selected local base ref * chore(ci): remove fork-only plus workflow * fix(worktrees): preserve exact branch picker refs --------- Co-authored-by: Matt Cowger <1929548+mcowger@users.noreply.github.com>
This commit is contained in:
co-authored by
Matt Cowger
parent
b0a5c09ace
commit
76abc50f82
@@ -2169,6 +2169,45 @@ const x = 1;
|
||||
expect(branches.find((branch) => branch.name === "feature/shared")).toMatchObject({
|
||||
hasLocal: true,
|
||||
hasRemote: true,
|
||||
localAhead: 0,
|
||||
localBehind: 0,
|
||||
});
|
||||
await expect(listBranchSuggestions(repoDir, { query: "origin/main" })).resolves.toEqual([
|
||||
expect.objectContaining({ name: "main", hasLocal: true, hasRemote: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports local and origin divergence for branch suggestions", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]);
|
||||
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir });
|
||||
execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir });
|
||||
|
||||
writeFileSync(join(repoDir, "local.txt"), "local\n");
|
||||
execFileSync("git", ["add", "local.txt"], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "local commit"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
const otherClone = join(tempDir, "diverged-clone");
|
||||
execFileSync("git", ["clone", remoteDir, otherClone]);
|
||||
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone });
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone });
|
||||
writeFileSync(join(otherClone, "remote.txt"), "remote\n");
|
||||
execFileSync("git", ["add", "remote.txt"], { cwd: otherClone });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote commit"], {
|
||||
cwd: otherClone,
|
||||
});
|
||||
execFileSync("git", ["push", "origin", "main"], { cwd: otherClone });
|
||||
execFileSync("git", ["fetch", "origin"], { cwd: repoDir });
|
||||
|
||||
const branches = await listBranchSuggestions(repoDir, { limit: 50 });
|
||||
|
||||
expect(branches.find((branch) => branch.name === "main")).toMatchObject({
|
||||
hasLocal: true,
|
||||
hasRemote: true,
|
||||
localAhead: 1,
|
||||
localBehind: 1,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -231,6 +231,7 @@ function normalizeBranchSuggestionName(raw: string): string | null {
|
||||
interface GitRef {
|
||||
name: string;
|
||||
committerDate: number;
|
||||
oid: string;
|
||||
}
|
||||
|
||||
export interface BranchSuggestion {
|
||||
@@ -238,6 +239,8 @@ export interface BranchSuggestion {
|
||||
committerDate: number;
|
||||
hasLocal: boolean;
|
||||
hasRemote: boolean;
|
||||
localAhead?: number;
|
||||
localBehind?: number;
|
||||
}
|
||||
|
||||
async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> {
|
||||
@@ -245,7 +248,7 @@ async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> {
|
||||
[
|
||||
"for-each-ref",
|
||||
"--sort=-committerdate",
|
||||
"--format=%(refname)%09%(committerdate:unix)",
|
||||
"--format=%(refname)%09%(committerdate:unix)%09%(objectname)",
|
||||
refPrefix,
|
||||
],
|
||||
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
|
||||
@@ -255,9 +258,9 @@ async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> {
|
||||
.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return null;
|
||||
const [name, dateStr] = trimmed.split("\t");
|
||||
if (!name) return null;
|
||||
return { name, committerDate: Number(dateStr) || 0 };
|
||||
const [name, dateStr, oid] = trimmed.split("\t");
|
||||
if (!name || !oid) return null;
|
||||
return { name, committerDate: Number(dateStr) || 0, oid };
|
||||
})
|
||||
.filter((ref): ref is GitRef => ref !== null);
|
||||
}
|
||||
@@ -266,6 +269,8 @@ interface BranchSuggestionMeta {
|
||||
committerDate: number;
|
||||
hasLocal: boolean;
|
||||
hasRemote: boolean;
|
||||
localOid?: string;
|
||||
remoteOid?: string;
|
||||
}
|
||||
|
||||
function sortBranchSuggestions(
|
||||
@@ -304,7 +309,8 @@ export async function listBranchSuggestions(
|
||||
|
||||
const requestedLimit = options?.limit ?? 50;
|
||||
const limit = Math.max(1, Math.min(200, requestedLimit));
|
||||
const query = options?.query?.trim().toLowerCase() ?? "";
|
||||
const rawQuery = options?.query?.trim().toLowerCase() ?? "";
|
||||
const query = normalizeBranchSuggestionName(rawQuery) ?? rawQuery;
|
||||
|
||||
const [localRefs, remoteRefs] = await Promise.all([
|
||||
listGitRefs(cwd, "refs/heads"),
|
||||
@@ -320,6 +326,8 @@ export async function listBranchSuggestions(
|
||||
branchMeta.set(normalized, {
|
||||
hasLocal: true,
|
||||
hasRemote: existing?.hasRemote ?? false,
|
||||
localOid: ref.oid,
|
||||
...(existing?.remoteOid ? { remoteOid: existing.remoteOid } : {}),
|
||||
committerDate: Math.max(ref.committerDate, existing?.committerDate ?? 0),
|
||||
});
|
||||
}
|
||||
@@ -332,12 +340,14 @@ export async function listBranchSuggestions(
|
||||
branchMeta.set(normalized, {
|
||||
hasLocal: false,
|
||||
hasRemote: true,
|
||||
remoteOid: ref.oid,
|
||||
committerDate: ref.committerDate,
|
||||
});
|
||||
} else {
|
||||
branchMeta.set(normalized, {
|
||||
...existing,
|
||||
hasRemote: true,
|
||||
remoteOid: ref.oid,
|
||||
committerDate: Math.max(ref.committerDate, existing.committerDate),
|
||||
});
|
||||
}
|
||||
@@ -351,15 +361,46 @@ export async function listBranchSuggestions(
|
||||
}
|
||||
|
||||
const ordered = sortBranchSuggestions(filteredNames, branchMeta, query);
|
||||
return ordered.slice(0, limit).map((name) => {
|
||||
const meta = branchMeta.get(name);
|
||||
return {
|
||||
name,
|
||||
committerDate: meta?.committerDate ?? 0,
|
||||
hasLocal: meta?.hasLocal ?? false,
|
||||
hasRemote: meta?.hasRemote ?? false,
|
||||
};
|
||||
});
|
||||
return Promise.all(
|
||||
ordered.slice(0, limit).map(async (name): Promise<BranchSuggestion> => {
|
||||
const meta = branchMeta.get(name);
|
||||
const suggestion: BranchSuggestion = {
|
||||
name,
|
||||
committerDate: meta?.committerDate ?? 0,
|
||||
hasLocal: meta?.hasLocal ?? false,
|
||||
hasRemote: meta?.hasRemote ?? false,
|
||||
};
|
||||
if (!suggestion.hasLocal || !suggestion.hasRemote) {
|
||||
return suggestion;
|
||||
}
|
||||
if (meta?.localOid && meta.localOid === meta.remoteOid) {
|
||||
suggestion.localAhead = 0;
|
||||
suggestion.localBehind = 0;
|
||||
return suggestion;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await runGitCommand(
|
||||
[
|
||||
"rev-list",
|
||||
"--left-right",
|
||||
"--count",
|
||||
`refs/heads/${name}...refs/remotes/origin/${name}`,
|
||||
],
|
||||
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
|
||||
);
|
||||
const [localAhead, localBehind] = stdout.trim().split(/\s+/).map(Number);
|
||||
if (Number.isFinite(localAhead) && Number.isFinite(localBehind)) {
|
||||
suggestion.localAhead = localAhead;
|
||||
suggestion.localBehind = localBehind;
|
||||
}
|
||||
} catch {
|
||||
// A ref may disappear between listing and comparison. Keep the branch
|
||||
// available without divergence metadata and let creation re-resolve it.
|
||||
}
|
||||
return suggestion;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export interface LocalBranchCheckoutResolution {
|
||||
|
||||
@@ -138,6 +138,12 @@ export function normalizeBaseRefName(input: string): string {
|
||||
if (!trimmed) {
|
||||
throw new Error("Base branch is required");
|
||||
}
|
||||
if (trimmed.startsWith("refs/heads/")) {
|
||||
return trimmed.slice("refs/heads/".length);
|
||||
}
|
||||
if (trimmed.startsWith("refs/remotes/origin/")) {
|
||||
return trimmed.slice("refs/remotes/origin/".length);
|
||||
}
|
||||
if (trimmed.startsWith("origin/")) {
|
||||
return trimmed.slice("origin/".length);
|
||||
}
|
||||
|
||||
@@ -407,7 +407,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
expect(currentBranch).toBe("Feature.X");
|
||||
});
|
||||
|
||||
it("prefers origin/{branch} over local {branch} when both exist", async () => {
|
||||
it("uses the selected local or origin ref when both exist", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
const remoteCloneDir = join(tempDir, "remote-clone");
|
||||
execFileSync("git", ["init", "--bare", remoteDir]);
|
||||
@@ -433,19 +433,33 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
|
||||
execFileSync("git", ["fetch", "origin"], { cwd: repoDir });
|
||||
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
branchName: "prefer-origin-feature",
|
||||
const localResult = await createLegacyWorktreeForTest({
|
||||
branchName: "prefer-local-feature",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "prefer-local-feature",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const originResult = await createLegacyWorktreeForTest({
|
||||
branchName: "prefer-origin-feature",
|
||||
cwd: repoDir,
|
||||
baseBranch: "refs/remotes/origin/main",
|
||||
worktreeSlug: "prefer-origin-feature",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-origin\n");
|
||||
expect(readFileSync(join(localResult.worktreePath, "file.txt"), "utf8")).toBe("from-local\n");
|
||||
expect(readFileSync(join(originResult.worktreePath, "file.txt"), "utf8")).toBe(
|
||||
"from-origin\n",
|
||||
);
|
||||
expect(
|
||||
JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(originResult.worktreePath), "utf8")),
|
||||
).toMatchObject({ baseRefName: "main" });
|
||||
});
|
||||
|
||||
it("falls back to local {branch} when origin/{branch} does not exist", async () => {
|
||||
it("uses a local branch when origin/{branch} does not exist", async () => {
|
||||
writeFileSync(join(repoDir, "file.txt"), "from-local-only\n");
|
||||
execFileSync("git", ["add", "file.txt"], { cwd: repoDir });
|
||||
execFileSync(
|
||||
|
||||
@@ -1325,7 +1325,7 @@ async function resolveWorktreeSourcePlan({
|
||||
const branchName = source.branchName;
|
||||
validateWorktreeBranchName(branchName);
|
||||
const normalizedBaseBranch = normalizeRequiredBaseBranch(source.baseBranch);
|
||||
const resolvedBaseBranch = await resolveBaseBranchForWorktree(cwd, normalizedBaseBranch);
|
||||
const resolvedBaseBranch = await resolveBaseBranchForWorktree(cwd, source.baseBranch);
|
||||
const branchExists = await localBranchExists(cwd, branchName);
|
||||
const base = branchExists ? branchName : resolvedBaseBranch;
|
||||
const candidateBranch = branchExists ? desiredSlug : branchName;
|
||||
@@ -1615,19 +1615,36 @@ function normalizeRequiredBaseBranch(baseBranch: string): string {
|
||||
|
||||
async function resolveBaseBranchForWorktree(
|
||||
cwd: string,
|
||||
normalizedBaseBranch: string,
|
||||
requestedBaseBranch: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
await runGitCommand(["rev-parse", "--verify", `origin/${normalizedBaseBranch}`], { cwd });
|
||||
return `origin/${normalizedBaseBranch}`;
|
||||
} catch {
|
||||
const requested = requestedBaseBranch.trim();
|
||||
const normalized = normalizeRequiredBaseBranch(requested);
|
||||
let exactRef: string | null = null;
|
||||
if (requested.startsWith("refs/")) {
|
||||
exactRef = requested;
|
||||
} else if (requested.startsWith("origin/")) {
|
||||
exactRef = `refs/remotes/${requested}`;
|
||||
}
|
||||
|
||||
if (exactRef) {
|
||||
try {
|
||||
await runGitCommand(["rev-parse", "--verify", normalizedBaseBranch], { cwd });
|
||||
return normalizedBaseBranch;
|
||||
await runGitCommand(["rev-parse", "--verify", exactRef], { cwd });
|
||||
return exactRef;
|
||||
} catch {
|
||||
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
|
||||
throw new Error(`Base branch not found: ${normalized}`);
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = [`refs/heads/${requested}`, `refs/remotes/origin/${requested}`, requested];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await runGitCommand(["rev-parse", "--verify", candidate], { cwd });
|
||||
return candidate;
|
||||
} catch {
|
||||
// Try the next unambiguous local, remote, or legacy ref candidate.
|
||||
}
|
||||
}
|
||||
throw new Error(`Base branch not found: ${normalized}`);
|
||||
}
|
||||
|
||||
async function localBranchExists(cwd: string, branchName: string): Promise<boolean> {
|
||||
|
||||
Reference in New Issue
Block a user