Improve branch switch safety and recent branch status (#3302)
* feat(ui): block branch switches on dirty trees * feat(ui): show unpushed commits in git branch selector * feat(ui): show recent branches in git selector * fix(ui): persist recent branch status * feat(ui): add mobile branch picker * fix(ui): guard mobile branch checkout * fix(i18n): restore Turkish git empty state labels * feat(ui): flag dirty draft directories on the branch selector Replaces the draft dirty-directory banner with an indicator on the branch selector: a warning icon plus a hover tooltip that opens by itself for five seconds when the dirty state first appears, then stays hover-only. The copy states the situation and the options (commit or worktree) without prescribing either. * feat(ui): optional push in the dirty branch switch dialog Commit-and-switch gains an opt-in "Push after commit" checkbox. When the push fails the commit stands but the switch is cancelled with an explicit toast, so the user is never moved off a branch without knowing its push did not happen. Without the checkbox the toast states the commit is local only. * fix(i18n): align dirty-directory copy across locales * fix(a11y): name the unpushed-commit badge in the branch picker The badge showed a bare arrow and number with no accessible name or tooltip. Both the desktop recents list and the mobile picker now carry a localized "N commits not pushed" title and aria-label. * fix(mobile): push before switching dirty branches Honor the dirty-switch dialog's push option on the mobile Changes surface. A failed push leaves the new commit on its source branch, refreshes state, and cancels checkout. Mobile branch selection now also shows the existing dirty switch notice.
This commit is contained in:
@@ -39,6 +39,7 @@ The following functions are exported and used by the web server:
|
||||
|
||||
### Branch Operations
|
||||
- `getBranches(directory)`: Get list of local and remote branches (filtered to active remote branches).
|
||||
- `getUnpushedBranchCounts(directory, branchNames)`: Count commits ahead of each locally known upstream for up to five supplied local branches. This reads local refs only and omits branches without an upstream.
|
||||
- `createBranch(directory, branchName, options)`: Create and checkout a new branch.
|
||||
- `checkoutBranch(directory, branchName)`: Checkout an existing branch. A remote-tracking name (`origin/main`, or the `remotes/`-prefixed form) resolves to the local branch of that name, created with `--track` when it does not exist yet, because the branch selector offers remote branches as places to work rather than commits to inspect — a literal checkout of the remote ref would detach HEAD. A local branch whose own name looks like a remote ref wins over that resolution, and anything unresolvable is checked out as requested. The returned `branch` is the branch that was actually checked out, which callers should report instead of the requested name.
|
||||
- `deleteBranch(directory, branch, options)`: Delete a branch (supports force flag).
|
||||
|
||||
@@ -873,6 +873,22 @@ export function registerGitRoutes(app) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/branch-push-status', async (req, res) => {
|
||||
const { getUnpushedBranchCounts } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
const branches = req.body?.branches;
|
||||
if (!directory) return res.status(400).json({ error: 'directory parameter is required' });
|
||||
if (!Array.isArray(branches) || branches.some((branch) => typeof branch !== 'string')) {
|
||||
return res.status(400).json({ error: 'branches must be an array of branch names' });
|
||||
}
|
||||
res.json(await getUnpushedBranchCounts(directory, branches));
|
||||
} catch (error) {
|
||||
console.error('Failed to get branch push status:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to get branch push status' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/branches', async (req, res) => {
|
||||
const { createBranch } = await getGitLibraries();
|
||||
try {
|
||||
|
||||
@@ -3687,6 +3687,35 @@ export async function getBranches(directory) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts locally unpushed commits for a small caller-supplied set of local
|
||||
* branches. This deliberately reads only local refs: the branch picker calls
|
||||
* it when opened, never polls, and never fetches a remote behind the user's
|
||||
* back. Unknown, remote, and upstream-less branches are omitted.
|
||||
*/
|
||||
export async function getUnpushedBranchCounts(directory, branchNames) {
|
||||
const { git } = await createRepositoryGitContext(directory);
|
||||
const requested = [...new Set(Array.isArray(branchNames) ? branchNames : [])]
|
||||
.filter((name) => typeof name === 'string' && name.length > 0)
|
||||
.slice(0, 5);
|
||||
if (requested.length === 0) return { counts: {} };
|
||||
|
||||
const local = new Set((await git.branchLocal()).all);
|
||||
const counts = {};
|
||||
await Promise.all(requested.map(async (branch) => {
|
||||
if (!local.has(branch)) return;
|
||||
const upstream = await git.raw(['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branch}@{upstream}`])
|
||||
.then((value) => value.trim())
|
||||
.catch(() => '');
|
||||
if (!upstream) return;
|
||||
const count = await git.raw(['rev-list', '--count', `${upstream}..${branch}`])
|
||||
.then((value) => Number.parseInt(value.trim(), 10))
|
||||
.catch(() => 0);
|
||||
if (Number.isFinite(count) && count > 0) counts[branch] = count;
|
||||
}));
|
||||
return { counts };
|
||||
}
|
||||
|
||||
async function getRemoteDefaultBranches(git) {
|
||||
let defaults = {};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createWorktree,
|
||||
getWorktreeBootstrapStatus,
|
||||
getBranches,
|
||||
getUnpushedBranchCounts,
|
||||
getRangeDiff,
|
||||
getStatus,
|
||||
getWorktrees,
|
||||
@@ -1498,6 +1499,21 @@ describe.runIf(canRunGit())('getBranches', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(canRunGit())('getUnpushedBranchCounts', () => {
|
||||
it('counts only commits ahead of a locally known upstream', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
runGit(repository, ['branch', '--set-upstream-to=origin/react', 'next']);
|
||||
fs.writeFileSync(path.join(repository, 'ahead.txt'), 'ahead\n');
|
||||
runGit(repository, ['add', 'ahead.txt']);
|
||||
runGit(repository, ['commit', '-m', 'ahead']);
|
||||
runGit(repository, ['checkout', '-b', 'no-upstream']);
|
||||
|
||||
await expect(getUnpushedBranchCounts(repository, ['next', 'no-upstream', 'remotes/origin/react'])).resolves.toEqual({
|
||||
counts: { next: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(canRunGit())('getRangeDiff', () => {
|
||||
it('resolves a base that exists only on a remote other than origin', async () => {
|
||||
const { repository } = createRepositoryWithRemote({ remoteName: 'upstream', defaultBranch: 'react' });
|
||||
|
||||
@@ -23,6 +23,7 @@ export const createWebGitAPI = (): GitAPI => ({
|
||||
revertGitHunk: gitApiHttp.revertGitHunk,
|
||||
isLinkedWorktree: gitApiHttp.isLinkedWorktree,
|
||||
getGitBranches: gitApiHttp.getGitBranches,
|
||||
getGitUnpushedBranchCounts: gitApiHttp.getGitUnpushedBranchCounts,
|
||||
deleteGitBranch: gitApiHttp.deleteGitBranch as GitAPI['deleteGitBranch'],
|
||||
deleteRemoteBranch: gitApiHttp.deleteRemoteBranch as GitAPI['deleteRemoteBranch'],
|
||||
removeRemote: gitApiHttp.removeRemote as GitAPI['removeRemote'],
|
||||
|
||||
Reference in New Issue
Block a user