fix: harden and de-slop the merged sidebar/chat/settings batch
Post-merge follow-ups for #2740 #2735 #2734 #2690 #2676 #2738 #2684 #2689 #2733 #2739 #2462 #2687 #2736 #2618 #2697, plus three regressions found while reviewing them: - ctrl/cmd+digit while typing no longer switches session tabs (#2503 was still open in practice: the guard only covered the mod+alt surface binding) - Shiki template-call sanitizer now covers every bundled grammar, including the js/ts aliases and embedding grammars; timed-out highlight requests are memoized and no longer cancel unrelated in-flight requests - settings flush on suspend uses keepalive and also fires on Capacitor appStateChange; keeps the selected model persisted across mode switches - remote-only branches fetch before checkout; range helpers fail clearly - git status invalidation now fires for runtime adapters too - settings number inputs and select triggers size in ch so they scale with the interface font - recent-activity timestamps tick from one list-level ticker - Markdown preview find goes through the shared find_in_file keybind with containment, no longer counts its own bar, and debounces observer runs - #2676 reverted; #2524 fixed by fading the sticky header's own background instead of overlaying the content below it - sticky group headers in the model picker and sidebar render again (oc-sticky-fade-scroller class restored after9b9d7069c) - project switcher names are left-aligned again (wrapper lost in26dbc2f30) - tool card quick-open icon is always visible and opens the same line as the expanded card's button - tautological tests replaced or removed; new oxlint findings fixed
This commit is contained in:
@@ -123,7 +123,8 @@ The following functions are internal helpers used by exported functions:
|
||||
### Branches Response
|
||||
- `all`: Local branches plus every branch each reachable remote reports via `ls-remote --heads`, formatted as `remotes/<remote>/<branch>`. This is a union: local remote-tracking refs deleted on the remote are pruned, and branches that exist on the remote without a local tracking ref (never fetched) are still included, so a freshly pushed branch appears without requiring a fetch. A remote that fails to answer keeps its locally known branches in the list: "we could not ask" must not be reported as "these branches are gone", because callers use this list to decide whether a base branch exists at all.
|
||||
- `current`: Current branch name.
|
||||
- `branches`: Per-branch detail keyed by branch name, as reported by `git branch`.
|
||||
- `branches`: Per-branch detail keyed by branch name, as reported by `git branch`. Remote-only entries in `all` — branches `ls-remote` reported that were never fetched — have **no** entry here, because `git branch` never saw them. Consumers must treat a missing detail entry as normal and read the name from `all`.
|
||||
- Never-fetched remote-only branches also have no local ref, so any operation that resolves one locally has to account for that: `checkoutBranch` fetches the single branch (`git fetch <remote> <branch>`) before creating the tracking branch, and the range helpers (`getRangeDiff`, `getRangeFiles`) reject an unresolvable ref with `Ref "<ref>" is not available locally. Fetch it before comparing.` instead of surfacing git's "ambiguous argument".
|
||||
- `defaultBranches`: Each remote's default branch, keyed by remote name. Read from the local `remotes/<name>/HEAD` symbolic ref; for a remote that has none — clone writes it, a hand-added remote may not — the remote itself is asked once with `ls-remote --symref`. A remote that answers neither is absent rather than guessed, and consumers fall back to conventional branch names. Omitted entirely by runtimes that do not provide this Git metadata.
|
||||
|
||||
### Runtime availability of range diffs
|
||||
|
||||
@@ -2599,6 +2599,25 @@ export async function getUntrackedDiffs(directory, filePaths = [], { concurrency
|
||||
return results;
|
||||
}
|
||||
|
||||
const refResolvesToCommit = async (git, ref) => git
|
||||
.raw(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
|
||||
.then((value) => Boolean(String(value || '').trim()))
|
||||
.catch(() => false);
|
||||
|
||||
/**
|
||||
* The branch list includes remote-only branches that `ls-remote` reported but
|
||||
* the repository never fetched (#2098), so a comparison can name a ref that does
|
||||
* not exist locally. Say that plainly instead of letting git's "ambiguous
|
||||
* argument" surface as an opaque failure.
|
||||
*/
|
||||
async function assertRangeRefsResolve(git, refs) {
|
||||
for (const ref of refs) {
|
||||
if (!(await refResolvesToCommit(git, ref))) {
|
||||
throw new Error(`Ref "${ref}" is not available locally. Fetch it before comparing.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRangeDiff(directory, { base, head, path: filePath, contextLines = 3 } = {}) {
|
||||
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
||||
const baseRef = typeof base === 'string' ? base.trim() : '';
|
||||
@@ -2641,6 +2660,8 @@ export async function getRangeDiff(directory, { base, head, path: filePath, cont
|
||||
}
|
||||
}
|
||||
|
||||
await assertRangeRefsResolve(git, [resolvedBase, headRef]);
|
||||
|
||||
const args = ['diff', '--no-color'];
|
||||
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
|
||||
args.push(`-U${Math.max(0, contextLines)}`);
|
||||
@@ -2741,6 +2762,8 @@ export async function getRangeFiles(directory, { base, head } = {}) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
await assertRangeRefsResolve(git, [resolvedBase, headRef]);
|
||||
|
||||
// `-C` (copy detection among changed files only, so cheap) makes copies
|
||||
// surface as C entries instead of plain additions; rename detection is on
|
||||
// by default.
|
||||
@@ -3824,10 +3847,6 @@ const resolveBranchCheckoutTarget = async (git, branchName) => {
|
||||
}
|
||||
|
||||
const remoteRef = requested.replace(/^remotes\//, '');
|
||||
if (!(await gitRefExists(git, `refs/remotes/${remoteRef}`))) {
|
||||
return asRequested;
|
||||
}
|
||||
|
||||
const remotes = await git.getRemotes();
|
||||
const remote = remotes.find((entry) => entry?.name && remoteRef.startsWith(`${entry.name}/`));
|
||||
if (!remote) {
|
||||
@@ -3840,6 +3859,22 @@ const resolveBranchCheckoutTarget = async (git, branchName) => {
|
||||
return asRequested;
|
||||
}
|
||||
|
||||
// The branch list also carries branches that only `ls-remote` knows about
|
||||
// (#2098): they exist on the remote but were never fetched, so there is no
|
||||
// remote-tracking ref and a literal checkout fails with a pathspec error.
|
||||
// Fetch the single branch first so the tracking ref exists, then fall through
|
||||
// to the normal create-with-tracking path.
|
||||
if (!(await gitRefExists(git, `refs/remotes/${remoteRef}`))) {
|
||||
try {
|
||||
await git.fetch(remote.name, localBranch);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch ${localBranch} from ${remote.name}: ${error?.message || error}`);
|
||||
}
|
||||
if (!(await gitRefExists(git, `refs/remotes/${remoteRef}`))) {
|
||||
throw new Error(`Branch ${localBranch} no longer exists on remote ${remote.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const localExists = await gitRefExists(git, `refs/heads/${localBranch}`);
|
||||
return { branch: localBranch, remoteRef: localExists ? null : remoteRef };
|
||||
};
|
||||
|
||||
@@ -1111,6 +1111,32 @@ describe('checkoutBranch', () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
await expect(checkoutBranch(repository, 'does-not-exist')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('fetches a remote-only branch that was never fetched locally (#2735)', async () => {
|
||||
const { repository, remote } = createRepositoryWithRemote({ defaultBranch: 'react' });
|
||||
// A collaborator pushes straight to the remote; this repository never
|
||||
// fetches, so `remotes/origin/collab` is listed (#2098) with no local ref.
|
||||
const collaborator = createTempDir();
|
||||
runGit(collaborator, ['clone', remote, '.']);
|
||||
runGit(collaborator, ['config', 'user.email', 'test@example.com']);
|
||||
runGit(collaborator, ['config', 'user.name', 'Test']);
|
||||
runGit(collaborator, ['checkout', '-b', 'collab']);
|
||||
runGit(collaborator, ['push', 'origin', 'collab']);
|
||||
|
||||
const result = await checkoutBranch(repository, 'remotes/origin/collab');
|
||||
|
||||
expect(result).toEqual({ success: true, branch: 'collab' });
|
||||
expect(runGit(repository, ['rev-parse', '--abbrev-ref', 'HEAD']).trim()).toBe('collab');
|
||||
expect(runGit(repository, ['rev-parse', '--abbrev-ref', 'collab@{upstream}']).trim()).toBe('origin/collab');
|
||||
});
|
||||
|
||||
it('reports a clear failure when the remote branch no longer exists', async () => {
|
||||
const { repository } = createRepositoryWithRemote({ defaultBranch: 'react' });
|
||||
|
||||
await expect(checkoutBranch(repository, 'remotes/origin/never-pushed')).rejects.toThrow(
|
||||
/Failed to fetch never-pushed from origin/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1485,6 +1511,14 @@ describe.runIf(canRunGit())('getRangeDiff', () => {
|
||||
|
||||
expect(diff).toContain('feature.txt');
|
||||
});
|
||||
|
||||
it('names an unfetched remote-only ref instead of failing with git\'s ambiguous argument (#2735)', async () => {
|
||||
const { repository } = createRepositoryWithRemote({ defaultBranch: 'react' });
|
||||
|
||||
await expect(
|
||||
getRangeDiff(repository, { base: 'remotes/origin/never-fetched', head: 'next' })
|
||||
).rejects.toThrow(/is not available locally/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseBranchCreationSource', () => {
|
||||
|
||||
@@ -147,7 +147,10 @@ other runtime API.
|
||||
resolution stays authoritative. Wired into `getManagedOpenCodeEnv` in
|
||||
`server/index.js`; the pure helper is unit-tested in
|
||||
`config-injection.test.js`. External OpenCode servers are unaffected (they
|
||||
are not launched with this env).
|
||||
are not launched with this env). The injected `small_model` is baked into
|
||||
`OPENCODE_CONFIG_CONTENT` when the managed process spawns, so changing the
|
||||
override in Settings applies on the next managed OpenCode restart, not to
|
||||
the process already running.
|
||||
|
||||
## Which providers the pickers may offer
|
||||
|
||||
|
||||
Reference in New Issue
Block a user