diff --git a/package-lock.json b/package-lock.json index 518fdee..2cada43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "git-graph-plus", - "version": "0.3.12", + "version": "0.7.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "git-graph-plus", - "version": "0.3.12", + "version": "0.7.3", "license": "Apache-2.0", "dependencies": { "@vscode/codicons": "^0.0.45" @@ -1417,7 +1417,6 @@ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", @@ -1567,7 +1566,6 @@ "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1985,7 +1983,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3439,7 +3436,6 @@ "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -5581,7 +5577,6 @@ "integrity": "sha512-ymI5ykLPwIHW839E053FQbI1G+jnRFJEw3Kv5Y4njixVWywQBx+NUFpkkKyk5LIb36Fg9DVXSYpqiGekLD0hyw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -5994,7 +5989,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6244,7 +6238,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -6361,7 +6354,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6395,7 +6387,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/src/git/__tests__/git-error-formatter.test.ts b/src/git/__tests__/git-error-formatter.test.ts index 9a7daec..a7e850c 100644 --- a/src/git/__tests__/git-error-formatter.test.ts +++ b/src/git/__tests__/git-error-formatter.test.ts @@ -28,6 +28,14 @@ describe('formatGitError', () => { ].join('\n'); expect(formatGitError(stderr)).toBe("failed to push some refs to 'origin'"); }); + + it('drops git-flow default-branch notice when a real error follows', () => { + const stderr = [ + 'Using default branch names.', + 'fatal: Not a gitflow-enabled repo yet. Please run "git flow init" first.', + ].join('\n'); + expect(formatGitError(stderr)).toBe('Not a gitflow-enabled repo yet. Please run "git flow init" first.'); + }); }); describe('file list after error', () => { diff --git a/src/git/__tests__/git-service.extra.test.ts b/src/git/__tests__/git-service.extra.test.ts index 3b0ee02..9ebc530 100644 --- a/src/git/__tests__/git-service.extra.test.ts +++ b/src/git/__tests__/git-service.extra.test.ts @@ -1,10 +1,15 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { GitService, GitError } from '../git-service'; +import type { BranchInfo } from '../types'; function mockExec(service: GitService, fn: (args: string[]) => Promise) { (service as unknown as { exec: (args: string[]) => Promise }).exec = fn; } +function branch(name: string, over: Partial = {}): BranchInfo { + return { name, current: false, ahead: 0, behind: 0, hash: 'abc1234', ...over }; +} + describe('GitService — rootPath', () => { it('returns the path passed to the constructor', () => { const s = new GitService('/some/path'); @@ -183,38 +188,153 @@ describe('GitService — LFS', () => { describe('GitService — git-flow shortcuts', () => { let service: GitService; - beforeEach(() => { service = new GitService('/tmp/repo'); }); + beforeEach(() => { + service = new GitService('/tmp/repo'); + (service as unknown as { branches: () => Promise }).branches = async () => [ + branch('main', { upstream: 'origin/main' }), + branch('develop', { upstream: 'origin/develop' }), + branch('feature/login', { upstream: 'origin/feature/login' }), + branch('release/1.0', { upstream: 'origin/release/1.0' }), + branch('hotfix/1.0.1', { upstream: 'origin/hotfix/1.0.1' }), + ]; + }); + + const flowConfigResponses: Record = { + 'config --local --get gitflow.branch.master': 'main', + 'config --local --get gitflow.branch.develop': 'develop', + 'config --local --get gitflow.prefix.feature': 'feature/', + 'config --local --get gitflow.prefix.release': 'release/', + 'config --local --get gitflow.prefix.hotfix': 'hotfix/', + 'config --local --get gitflow.prefix.versiontag': 'v', + }; it('flowFeatureStart runs git flow feature start ', async () => { const calls: string[][] = []; - mockExec(service, async (args) => { calls.push(args); return 'ok'; }); + mockExec(service, async (args) => { + calls.push(args); + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + if (key === 'show-ref --verify --quiet refs/heads/feature/login') return ''; + return 'ok'; + }); await service.flowFeatureStart('login'); - expect(calls[0]).toEqual(['flow', 'feature', 'start', 'login']); + expect(calls).toContainEqual(['flow', 'feature', 'start', 'login']); }); it('flowFeatureFinish runs git flow feature finish ', async () => { const calls: string[][] = []; - mockExec(service, async (args) => { calls.push(args); return ''; }); + mockExec(service, async (args) => { + calls.push(args); + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + return ''; + }); await service.flowFeatureFinish('login'); - expect(calls[0]).toEqual(['flow', 'feature', 'finish', 'login']); + expect(calls).toContainEqual(['flow', 'feature', 'finish', 'login']); }); it('flowReleaseStart and flowReleaseFinish include -m message for finish', async () => { const calls: string[][] = []; - mockExec(service, async (args) => { calls.push(args); return ''; }); + mockExec(service, async (args) => { + calls.push(args); + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + return ''; + }); await service.flowReleaseStart('1.0'); await service.flowReleaseFinish('1.0'); - expect(calls[0]).toEqual(['flow', 'release', 'start', '1.0']); - expect(calls[1]).toEqual(['flow', 'release', 'finish', '-m', '1.0', '1.0']); + expect(calls).toContainEqual(['flow', 'release', 'start', '1.0']); + expect(calls).toContainEqual(['flow', 'release', 'finish', '-m', '1.0', '1.0']); }); it('flowHotfixStart and flowHotfixFinish behave the same way', async () => { const calls: string[][] = []; - mockExec(service, async (args) => { calls.push(args); return ''; }); + mockExec(service, async (args) => { + calls.push(args); + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + return ''; + }); await service.flowHotfixStart('1.0.1'); await service.flowHotfixFinish('1.0.1'); - expect(calls[0]).toEqual(['flow', 'hotfix', 'start', '1.0.1']); - expect(calls[1]).toEqual(['flow', 'hotfix', 'finish', '-m', '1.0.1', '1.0.1']); + expect(calls).toContainEqual(['flow', 'hotfix', 'start', '1.0.1']); + expect(calls).toContainEqual(['flow', 'hotfix', 'finish', '-m', '1.0.1', '1.0.1']); + }); + + it('flowFeatureStart rejects when git-flow reports success but the branch is missing', async () => { + mockExec(service, async (args) => { + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + if (key === 'flow feature start login') return 'ok'; + if (key === 'show-ref --verify --quiet refs/heads/feature/login') { + throw new GitError('', 1, args); + } + return ''; + }); + + await expect(service.flowFeatureStart('login')) + .rejects.toThrow("branch 'feature/login' was not created"); + }); + + it('flowHotfixStart pulls with rebase when the production branch is behind upstream', async () => { + (service as unknown as { branches: () => Promise }).branches = async () => [ + branch('main', { upstream: 'origin/main', behind: 2 }), + branch('develop', { current: true, upstream: 'origin/develop' }), + ]; + const calls: string[][] = []; + mockExec(service, async (args) => { + calls.push(args); + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + if (key === 'show-ref --verify --quiet refs/heads/hotfix/1.0.1') return ''; + return ''; + }); + + await service.flowHotfixStart('1.0.1'); + + expect(calls).toContainEqual(['checkout', 'main']); + expect(calls).toContainEqual(['pull', '--rebase']); + expect(calls).toContainEqual(['checkout', 'develop']); + expect(calls).toContainEqual(['flow', 'hotfix', 'start', '1.0.1']); + }); + + it('flowReleaseFinish pulls with rebase when a related branch is behind upstream', async () => { + (service as unknown as { branches: () => Promise }).branches = async () => [ + branch('main', { current: true, upstream: 'origin/main' }), + branch('develop', { upstream: 'origin/develop', behind: 1 }), + branch('release/1.0', { upstream: 'origin/release/1.0' }), + ]; + const calls: string[][] = []; + mockExec(service, async (args) => { + calls.push(args); + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + return ''; + }); + + await service.flowReleaseFinish('1.0'); + + expect(calls).toContainEqual(['checkout', 'develop']); + expect(calls).toContainEqual(['pull', '--rebase']); + expect(calls).toContainEqual(['checkout', 'main']); + expect(calls).toContainEqual(['flow', 'release', 'finish', '-m', '1.0', '1.0']); + }); + + it('flowHotfixStart stops before git-flow when pull --rebase fails', async () => { + (service as unknown as { branches: () => Promise }).branches = async () => [ + branch('main', { current: true, upstream: 'origin/main', behind: 1 }), + ]; + const calls: string[][] = []; + mockExec(service, async (args) => { + calls.push(args); + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + if (key === 'pull --rebase') throw new GitError('rebase conflict', 1, args); + return ''; + }); + + await expect(service.flowHotfixStart('1.0.1')).rejects.toThrow('rebase conflict'); + expect(calls).not.toContainEqual(['flow', 'hotfix', 'start', '1.0.1']); }); it('rejects flow names that start with "-" (CLI option injection)', async () => { @@ -251,12 +371,12 @@ describe('GitService — getFlowBranches', () => { it('groups branches by configured prefixes', async () => { const responses: Record = { - 'config --get gitflow.branch.master': 'main', - 'config --get gitflow.branch.develop': 'develop', - 'config --get gitflow.prefix.feature': 'feature/', - 'config --get gitflow.prefix.release': 'release/', - 'config --get gitflow.prefix.hotfix': 'hotfix/', - 'config --get gitflow.prefix.versiontag': 'v', + 'config --local --get gitflow.branch.master': 'main', + 'config --local --get gitflow.branch.develop': 'develop', + 'config --local --get gitflow.prefix.feature': 'feature/', + 'config --local --get gitflow.prefix.release': 'release/', + 'config --local --get gitflow.prefix.hotfix': 'hotfix/', + 'config --local --get gitflow.prefix.versiontag': 'v', 'branch --list': [ '* develop', ' feature/login', diff --git a/src/git/__tests__/git-service.test.ts b/src/git/__tests__/git-service.test.ts index 923a610..83e3bdd 100644 --- a/src/git/__tests__/git-service.test.ts +++ b/src/git/__tests__/git-service.test.ts @@ -1,11 +1,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { GitService, GitError, binCommitTime, buildAmendCommandStr } from '../git-service'; +import type { BranchInfo } from '../types'; // Access private exec method via prototype for mocking function mockExec(service: GitService, fn: (args: string[]) => Promise) { (service as any).exec = fn; } +function branch(name: string, over: Partial = {}): BranchInfo { + return { name, current: false, ahead: 0, behind: 0, hash: 'abc1234', ...over }; +} + describe('GitService', () => { let service: GitService; @@ -968,17 +973,52 @@ describe('GitService', () => { }); it('isFlowInitialized returns false when gitflow.branch.master is unset', async () => { - mockExec(service, async () => { throw new GitError('', 1, ['config', '--get', 'gitflow.branch.master']); }); + mockExec(service, async () => { throw new GitError('', 1, ['config', '--local', '--get', 'gitflow.branch.master']); }); expect(await service.isFlowInitialized()).toBe(false); }); + it('isFlowInitialized returns false when saved flow config points to a missing develop branch', async () => { + const responses: Record = { + 'config --local --get gitflow.branch.master': 'main', + 'config --local --get gitflow.branch.develop': 'develop', + 'config --local --get gitflow.prefix.feature': 'feature/', + 'config --local --get gitflow.prefix.release': 'release/', + 'config --local --get gitflow.prefix.hotfix': 'hotfix/', + 'config --local --get gitflow.prefix.versiontag': 'v', + 'show-ref --verify --quiet refs/heads/main': '', + }; + mockExec(service, async (args) => { + const key = args.join(' '); + if (key in responses) return responses[key]; + throw new GitError('', 1, args); + }); + + expect(await service.isFlowInitialized()).toBe(false); + }); + + it('isFlowInitialized returns true only when config and required branches exist', async () => { + const responses: Record = { + 'config --local --get gitflow.branch.master': 'main', + 'config --local --get gitflow.branch.develop': 'develop', + 'config --local --get gitflow.prefix.feature': 'feature/', + 'config --local --get gitflow.prefix.release': 'release/', + 'config --local --get gitflow.prefix.hotfix': 'hotfix/', + 'config --local --get gitflow.prefix.versiontag': 'v', + 'show-ref --verify --quiet refs/heads/main': '', + 'show-ref --verify --quiet refs/heads/develop': '', + }; + mockExec(service, async (args) => responses[args.join(' ')] ?? ''); + + expect(await service.isFlowInitialized()).toBe(true); + }); + it('getFlowConfig returns null when any required key is missing', async () => { // First config key resolves, second rejects → whole thing nulls out. let calls = 0; mockExec(service, async () => { calls++; if (calls === 1) return 'main\n'; - throw new GitError('', 1, ['config', '--get', 'gitflow.branch.develop']); + throw new GitError('', 1, ['config', '--local', '--get', 'gitflow.branch.develop']); }); expect(await service.getFlowConfig()).toBeNull(); }); @@ -1293,32 +1333,83 @@ describe('GitService', () => { hotfixPrefix: 'hotfix/', versionTagPrefix: 'v', }; + const flowConfigResponses: Record = { + 'config --local --get gitflow.branch.master': 'main', + 'config --local --get gitflow.branch.develop': 'develop', + 'config --local --get gitflow.prefix.feature': 'feature/', + 'config --local --get gitflow.prefix.release': 'release/', + 'config --local --get gitflow.prefix.hotfix': 'hotfix/', + 'config --local --get gitflow.prefix.versiontag': 'v', + }; + beforeEach(() => { + (service as unknown as { branches: () => Promise }).branches = async () => [ + branch('main', { upstream: 'origin/main' }), + branch('develop', { upstream: 'origin/develop' }), + ]; + }); it('creates develop branch from production when develop is missing', async () => { const calls: string[][] = []; - let revParseCount = 0; + let developCreated = false; mockExec(service, async (args) => { calls.push(args); - if (args[0] === 'rev-parse') { - revParseCount++; - // 1st: verify production (succeeds). 2nd: verify develop (fails). - if (revParseCount === 1) return 'abc123'; + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + if (key === 'show-ref --verify --quiet refs/heads/main') return ''; + if (key === 'show-ref --verify --quiet refs/heads/develop') { + if (developCreated) return ''; throw new GitError("unknown revision 'develop'", 1, args); } + if (args[0] === 'branch') { + developCreated = true; + return ''; + } return ''; }); await service.flowInit(flowOpts); + expect(calls).toContainEqual(['flow', 'init', '-f', '-d']); const branchCreate = calls.find(c => c[0] === 'branch'); expect(branchCreate).toEqual(['branch', 'develop', 'main']); }); + it('clears stale flow branch config before forced git-flow init', async () => { + const calls: string[][] = []; + mockExec(service, async (args) => { + calls.push(args); + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + if (key === 'show-ref --verify --quiet refs/heads/main') return ''; + if (key === 'show-ref --verify --quiet refs/heads/develop') return ''; + return ''; + }); + + await service.flowInit(flowOpts); + + const unsetMaster = calls.findIndex(c => c.join(' ') === 'config --local --unset-all gitflow.branch.master'); + const unsetDevelop = calls.findIndex(c => c.join(' ') === 'config --local --unset-all gitflow.branch.develop'); + const setMaster = calls.findIndex(c => c.join(' ') === 'config --local --replace-all gitflow.branch.master main'); + const setDevelop = calls.findIndex(c => c.join(' ') === 'config --local --replace-all gitflow.branch.develop develop'); + const init = calls.findIndex(c => c.join(' ') === 'flow init -f -d'); + expect(unsetMaster).toBeGreaterThanOrEqual(0); + expect(unsetDevelop).toBeGreaterThanOrEqual(0); + expect(setMaster).toBeGreaterThanOrEqual(0); + expect(setDevelop).toBeGreaterThanOrEqual(0); + expect(init).toBeGreaterThan(unsetMaster); + expect(init).toBeGreaterThan(unsetDevelop); + expect(init).toBeGreaterThan(setMaster); + expect(init).toBeGreaterThan(setDevelop); + }); + it('skips develop creation when develop already exists', async () => { const calls: string[][] = []; mockExec(service, async (args) => { calls.push(args); - if (args[0] === 'rev-parse') return 'abc123'; + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + if (key === 'show-ref --verify --quiet refs/heads/main') return ''; + if (key === 'show-ref --verify --quiet refs/heads/develop') return ''; return ''; }); @@ -1330,13 +1421,40 @@ describe('GitService', () => { it('throws a helpful error when production branch is missing', async () => { mockExec(service, async (args) => { - if (args[0] === 'rev-parse') throw new GitError('not found', 1, args); + if (args.join(' ') === 'show-ref --verify --quiet refs/heads/main') { + throw new GitError('not found', 1, args); + } return ''; }); await expect(service.flowInit(flowOpts)) .rejects.toThrow("Branch 'main' does not exist"); }); + + it('pulls with rebase before init when production branch is behind upstream', async () => { + (service as unknown as { branches: () => Promise }).branches = async () => [ + branch('main', { current: true, upstream: 'origin/main', behind: 1 }), + ]; + const calls: string[][] = []; + mockExec(service, async (args) => { + calls.push(args); + if (args.join(' ') === 'show-ref --verify --quiet refs/heads/main') return ''; + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + if (key === 'show-ref --verify --quiet refs/heads/develop') return ''; + return ''; + }); + + await service.flowInit(flowOpts); + + expect(calls).toContainEqual(['pull', '--rebase']); + expect(calls).toContainEqual(['flow', 'init', '-f', '-d']); + }); + + it('rejects using the same production and develop branch', async () => { + await expect(service.flowInit({ ...flowOpts, developBranch: 'main' })) + .rejects.toThrow('production and develop branches must be different'); + }); }); }); diff --git a/src/git/git-error-formatter.ts b/src/git/git-error-formatter.ts index 8dfbc61..1ad242a 100644 --- a/src/git/git-error-formatter.ts +++ b/src/git/git-error-formatter.ts @@ -27,7 +27,9 @@ function joinCapped(parts: string[]): string { export function formatGitError(stderr: string): string { // git pads `remote:` lines with trailing spaces; strip them so joined // messages stay clean. - const rawLines = stderr.split('\n').map(l => l.replace(/\s+$/, '')); + let rawLines = stderr.split('\n').map(l => l.replace(/\s+$/, '')); + const actionableLines = rawLines.filter(l => l.trim() !== 'Using default branch names.'); + if (actionableLines.length > 0) rawLines = actionableLines; if (rawLines.every(l => l.length === 0)) return stderr.trim(); // 1. Remote server error/fatal lines are most specific (e.g. GitHub rule diff --git a/src/git/git-service.ts b/src/git/git-service.ts index 71e6576..3eb391a 100644 --- a/src/git/git-service.ts +++ b/src/git/git-service.ts @@ -17,6 +17,15 @@ import { parseLog, parseBranches, parseTags, parseRemotes, parseStashList, parse import { buildReversePatch } from './patch-builder'; import type { Commit, BranchInfo, TagInfo, RemoteInfo, StashEntry, LogOptions, DiffData, WorktreeInfo, CommitSignature } from './types'; +type GitFlowConfig = { + productionBranch: string; + developBranch: string; + featurePrefix: string; + releasePrefix: string; + hotfixPrefix: string; + versionTagPrefix: string; +}; + export class GitError extends Error { constructor( public stderr: string, @@ -2185,6 +2194,81 @@ export class GitService { // --- Git Flow --- + private async localBranchExists(name: string): Promise { + this.assertSafeRef(name, 'branch exists'); + try { + await this.exec(['show-ref', '--verify', '--quiet', `refs/heads/${name}`], { silent: true }); + return true; + } catch { + return false; + } + } + + private async assertLocalBranchExists(name: string, operation: string): Promise { + if (await this.localBranchExists(name)) return; + throw new GitError(`${operation} reported success, but branch '${name}' was not created.`, 1, []); + } + + private async pullRebaseBranchesBehindUpstream(branchNames: string[]): Promise { + const names = Array.from(new Set(branchNames.filter(Boolean))); + if (names.length === 0) return; + const nameSet = new Set(names); + const branches = await this.branches(); + const behindBranches = branches + .filter(branch => !branch.remote && nameSet.has(branch.name) && !!branch.upstream && !branch.upstreamGone && branch.behind > 0); + if (behindBranches.length === 0) return; + + const currentBranch = branches.find(branch => branch.current && !branch.remote)?.name; + const originalRef = currentBranch + ?? await this.exec(['rev-parse', '--verify', 'HEAD'], { silent: true }).then(s => s.trim()).catch(() => undefined); + let checkedOutBranch = currentBranch; + + for (const branch of behindBranches) { + if (checkedOutBranch !== branch.name) { + await this.checkout(branch.name); + checkedOutBranch = branch.name; + } + await this.pull(undefined, undefined, { rebase: true }); + } + + if (originalRef && checkedOutBranch !== originalRef) { + await this.checkout(originalRef); + } + } + + private async requireFlowConfig(): Promise { + const config = await this.getFlowConfig(); + if (!config) { + throw new GitError('Git Flow is not initialized for this repository.', 1, ['flow']); + } + return config; + } + + private flowConfigMatches(config: GitFlowConfig, options: GitFlowConfig): boolean { + return config.productionBranch === options.productionBranch + && config.developBranch === options.developBranch + && config.featurePrefix === options.featurePrefix + && config.releasePrefix === options.releasePrefix + && config.hotfixPrefix === options.hotfixPrefix + && config.versionTagPrefix === options.versionTagPrefix; + } + + private async clearFlowBranchConfig(): Promise { + await Promise.all([ + this.exec(['config', '--local', '--unset-all', 'gitflow.branch.master'], { silent: true }).catch(() => undefined), + this.exec(['config', '--local', '--unset-all', 'gitflow.branch.develop'], { silent: true }).catch(() => undefined), + ]); + } + + private async writeFlowConfig(options: GitFlowConfig): Promise { + await this.exec(['config', '--local', '--replace-all', 'gitflow.branch.master', options.productionBranch]); + await this.exec(['config', '--local', '--replace-all', 'gitflow.branch.develop', options.developBranch]); + await this.exec(['config', '--local', '--replace-all', 'gitflow.prefix.feature', options.featurePrefix]); + await this.exec(['config', '--local', '--replace-all', 'gitflow.prefix.release', options.releasePrefix]); + await this.exec(['config', '--local', '--replace-all', 'gitflow.prefix.hotfix', options.hotfixPrefix]); + await this.exec(['config', '--local', '--replace-all', 'gitflow.prefix.versiontag', options.versionTagPrefix]); + } + async flowInit(options: { productionBranch: string; developBranch: string; @@ -2193,82 +2277,107 @@ export class GitService { hotfixPrefix: string; versionTagPrefix: string; }): Promise { - // production 브랜치 존재 여부 검증 - try { - await this.exec(['rev-parse', '--verify', options.productionBranch]); - } catch { + if (options.productionBranch === options.developBranch) { + throw new GitError('Git Flow production and develop branches must be different.', 1, ['flow', 'init']); + } + + if (!(await this.localBranchExists(options.productionBranch))) { throw new GitError( `Branch '${options.productionBranch}' does not exist. Create the production branch first or ensure at least one commit exists.`, 1, ['flow', 'init'] ); } + await this.pullRebaseBranchesBehindUpstream([options.productionBranch]); - // git flow init -d로 기본 초기화 후 커스텀 설정 덮어쓰기 - await this.exec(['flow', 'init', '-d']); - await this.exec(['config', 'gitflow.branch.master', options.productionBranch]); - await this.exec(['config', 'gitflow.branch.develop', options.developBranch]); - await this.exec(['config', 'gitflow.prefix.feature', options.featurePrefix]); - await this.exec(['config', 'gitflow.prefix.release', options.releasePrefix]); - await this.exec(['config', 'gitflow.prefix.hotfix', options.hotfixPrefix]); - await this.exec(['config', 'gitflow.prefix.versiontag', options.versionTagPrefix]); + await this.clearFlowBranchConfig(); + await this.exec(['config', '--local', '--replace-all', 'gitflow.branch.master', options.productionBranch]); + await this.exec(['config', '--local', '--replace-all', 'gitflow.branch.develop', options.developBranch]); + await this.exec(['flow', 'init', '-f', '-d']); - // develop 브랜치가 없으면 생성 - try { - await this.exec(['rev-parse', '--verify', options.developBranch]); - } catch { + if (!(await this.localBranchExists(options.developBranch))) { await this.exec(['branch', options.developBranch, options.productionBranch]); } + await this.assertLocalBranchExists(options.developBranch, 'Git Flow initialization'); + + await this.writeFlowConfig(options); + + const persistedConfig = await this.getFlowConfig(); + if (!persistedConfig || !this.flowConfigMatches(persistedConfig, options)) { + throw new GitError('Git Flow initialization did not persist the requested configuration.', 1, ['flow', 'init']); + } return 'Git Flow initialized'; } async flowFeatureStart(name: string): Promise { this.assertSafeRef(name, 'flow feature start'); - return this.exec(['flow', 'feature', 'start', name]); + const config = await this.requireFlowConfig(); + await this.pullRebaseBranchesBehindUpstream([config.developBranch]); + const result = await this.exec(['flow', 'feature', 'start', name]); + await this.assertLocalBranchExists(`${config.featurePrefix}${name}`, 'Git Flow feature start'); + return result; } async flowFeatureFinish(name: string): Promise { this.assertSafeRef(name, 'flow feature finish'); + const config = await this.requireFlowConfig(); + await this.pullRebaseBranchesBehindUpstream([ + config.developBranch, + `${config.featurePrefix}${name}`, + ]); return this.exec(['flow', 'feature', 'finish', name]); } async flowReleaseStart(version: string): Promise { this.assertSafeRef(version, 'flow release start'); - return this.exec(['flow', 'release', 'start', version]); + const config = await this.requireFlowConfig(); + await this.pullRebaseBranchesBehindUpstream([config.developBranch]); + const result = await this.exec(['flow', 'release', 'start', version]); + await this.assertLocalBranchExists(`${config.releasePrefix}${version}`, 'Git Flow release start'); + return result; } async flowReleaseFinish(version: string): Promise { this.assertSafeRef(version, 'flow release finish'); + const config = await this.requireFlowConfig(); + await this.pullRebaseBranchesBehindUpstream([ + config.productionBranch, + config.developBranch, + `${config.releasePrefix}${version}`, + ]); return this.exec(['flow', 'release', 'finish', '-m', version, version]); } async flowHotfixStart(version: string): Promise { this.assertSafeRef(version, 'flow hotfix start'); - return this.exec(['flow', 'hotfix', 'start', version]); + const config = await this.requireFlowConfig(); + await this.pullRebaseBranchesBehindUpstream([config.productionBranch]); + const result = await this.exec(['flow', 'hotfix', 'start', version]); + await this.assertLocalBranchExists(`${config.hotfixPrefix}${version}`, 'Git Flow hotfix start'); + return result; } async flowHotfixFinish(version: string): Promise { this.assertSafeRef(version, 'flow hotfix finish'); + const config = await this.requireFlowConfig(); + await this.pullRebaseBranchesBehindUpstream([ + config.productionBranch, + config.developBranch, + `${config.hotfixPrefix}${version}`, + ]); return this.exec(['flow', 'hotfix', 'finish', '-m', version, version]); } - async getFlowConfig(): Promise<{ - productionBranch: string; - developBranch: string; - featurePrefix: string; - releasePrefix: string; - hotfixPrefix: string; - versionTagPrefix: string; - } | null> { + async getFlowConfig(): Promise { try { const [production, develop, feature, release, hotfix, versionTag] = await Promise.all([ - this.exec(['config', '--get', 'gitflow.branch.master']).then(s => s.trim()), - this.exec(['config', '--get', 'gitflow.branch.develop']).then(s => s.trim()), - this.exec(['config', '--get', 'gitflow.prefix.feature']).then(s => s.trim()), - this.exec(['config', '--get', 'gitflow.prefix.release']).then(s => s.trim()), - this.exec(['config', '--get', 'gitflow.prefix.hotfix']).then(s => s.trim()), - this.exec(['config', '--get', 'gitflow.prefix.versiontag']).then(s => s.trim()).catch(() => ''), + this.exec(['config', '--local', '--get', 'gitflow.branch.master']).then(s => s.trim()), + this.exec(['config', '--local', '--get', 'gitflow.branch.develop']).then(s => s.trim()), + this.exec(['config', '--local', '--get', 'gitflow.prefix.feature']).then(s => s.trim()), + this.exec(['config', '--local', '--get', 'gitflow.prefix.release']).then(s => s.trim()), + this.exec(['config', '--local', '--get', 'gitflow.prefix.hotfix']).then(s => s.trim()), + this.exec(['config', '--local', '--get', 'gitflow.prefix.versiontag']).then(s => s.trim()).catch(() => ''), ]); return { productionBranch: production, @@ -2278,7 +2387,11 @@ export class GitService { hotfixPrefix: hotfix, versionTagPrefix: versionTag, }; - } catch (err) { console.warn('Git Graph+: failed to get flow config:', err instanceof Error ? err.message : err); return null; } + } catch (err) { + if (err instanceof GitError && err.exitCode === 1) return null; + console.warn('Git Graph+: failed to get flow config:', err instanceof Error ? err.message : err); + return null; + } } async getFlowBranches(): Promise<{ features: string[]; releases: string[]; hotfixes: string[] }> { @@ -2452,9 +2565,18 @@ export class GitService { async isFlowInitialized(): Promise { try { - await this.exec(['config', '--get', 'gitflow.branch.master']); - return true; - } catch (err) { console.warn('Git Graph+: flow init check failed:', err instanceof Error ? err.message : err); return false; } + const config = await this.getFlowConfig(); + if (!config) return false; + const [productionExists, developExists] = await Promise.all([ + this.localBranchExists(config.productionBranch), + this.localBranchExists(config.developBranch), + ]); + return productionExists && developExists; + } catch (err) { + if (err instanceof GitError && err.exitCode === 1) return false; + console.warn('Git Graph+: flow init check failed:', err instanceof Error ? err.message : err); + return false; + } } } diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index 6050171..13fd799 100644 --- a/webview-ui/package-lock.json +++ b/webview-ui/package-lock.json @@ -985,7 +985,6 @@ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", @@ -1335,7 +1334,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1718,7 +1716,6 @@ "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -2037,7 +2034,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2300,7 +2296,6 @@ "integrity": "sha512-ymI5ykLPwIHW839E053FQbI1G+jnRFJEw3Kv5Y4njixVWywQBx+NUFpkkKyk5LIb36Fg9DVXSYpqiGekLD0hyw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2424,7 +2419,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2542,7 +2536,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -2661,7 +2654,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/webview-ui/src/App.svelte b/webview-ui/src/App.svelte index c649320..c6adfb0 100644 --- a/webview-ui/src/App.svelte +++ b/webview-ui/src/App.svelte @@ -4,7 +4,7 @@ import { getVsCodeApi } from './lib/vscode-api'; import { commitStore } from './lib/stores/commits.svelte'; import { branchStore } from './lib/stores/branches.svelte'; - import { uiStore, BOTTOM_PANEL_DEFAULT_RATIO, BOTTOM_PANEL_MIN_RATIO, BOTTOM_PANEL_MAX_RATIO } from './lib/stores/ui.svelte'; + import { uiStore, BOTTOM_PANEL_MIN_RATIO, BOTTOM_PANEL_MAX_RATIO } from './lib/stores/ui.svelte'; import { i18n, t } from './lib/i18n/index.svelte'; import CommitGraph from './components/graph/CommitGraph.svelte'; import BottomPanel from './components/layout/BottomPanel.svelte'; @@ -83,8 +83,6 @@ import AmendModal from './components/modals/AmendModal.svelte'; }); onMount(() => { - uiStore.bottomPanelHeight = Math.round(window.innerHeight * BOTTOM_PANEL_DEFAULT_RATIO); - function handleMessage(event: MessageEvent) { const msg = event.data; switch (msg.type) { @@ -341,11 +339,14 @@ import AmendModal from './components/modals/AmendModal.svelte'; e.preventDefault(); resizing = true; const startY = e.clientY; - const startHeight = uiStore.bottomPanelHeight; + const startRatio = uiStore.bottomPanelRatio; + const container = (e.currentTarget as HTMLElement).parentElement; + const containerHeight = container?.clientHeight || window.innerHeight; function onMouseMove(e: MouseEvent) { const delta = startY - e.clientY; - uiStore.bottomPanelHeight = Math.max(window.innerHeight * BOTTOM_PANEL_MIN_RATIO, Math.min(window.innerHeight * BOTTOM_PANEL_MAX_RATIO, startHeight + delta)); + const nextRatio = startRatio + delta / containerHeight; + uiStore.bottomPanelRatio = Math.max(BOTTOM_PANEL_MIN_RATIO, Math.min(BOTTOM_PANEL_MAX_RATIO, nextRatio)); } function onMouseUp() { @@ -498,7 +499,7 @@ import AmendModal from './components/modals/AmendModal.svelte';
{/if} -
+
{/if} @@ -1119,11 +1120,6 @@ import AmendModal from './components/modals/AmendModal.svelte'; .bottom-area { overflow: hidden; flex-shrink: 0; - /* bottomPanelHeight is a fixed px value that isn't recomputed when the - viewport shrinks (e.g. opening the terminal). Cap the panel to the - available area so it can never overflow .content-area and get clipped; - BottomPanel then scrolls internally instead of becoming unreachable. */ - max-height: 80%; border-top: 1px solid var(--border-color); } diff --git a/webview-ui/src/__tests__/App.test.ts b/webview-ui/src/__tests__/App.test.ts index 69b013b..86a78f3 100644 --- a/webview-ui/src/__tests__/App.test.ts +++ b/webview-ui/src/__tests__/App.test.ts @@ -4,7 +4,7 @@ import App from '../App.svelte'; import { i18n } from '../lib/i18n/index.svelte'; import { commitStore } from '../lib/stores/commits.svelte'; import { branchStore } from '../lib/stores/branches.svelte'; -import { uiStore } from '../lib/stores/ui.svelte'; +import { uiStore, BOTTOM_PANEL_DEFAULT_RATIO } from '../lib/stores/ui.svelte'; import { modalStore } from '../lib/stores/modals.svelte'; function postMsg(type: string, payload?: unknown) { @@ -25,6 +25,7 @@ function resetStores() { uiStore.comparing = false; uiStore.commitDetailFullscreen = false; uiStore.showBottomPanel = true; + uiStore.bottomPanelRatio = BOTTOM_PANEL_DEFAULT_RATIO; uiStore.commitFileSelected = false; uiStore.repos = []; uiStore.activeRepo = ''; @@ -1076,11 +1077,11 @@ describe('App — bottom panel resize handle', () => { const { container } = render(App); await waitFor(() => container.querySelector('.resize-handle-h')); const handle = container.querySelector('.resize-handle-h')!; - const startHeight = uiStore.bottomPanelHeight; + const startRatio = uiStore.bottomPanelRatio; await fireEvent.mouseDown(handle, { clientY: 500 }); - // Move up → height increases (deltaY negative from window.innerHeight - clientY) + // Move up → bottom panel ratio increases. await fireEvent.mouseMove(window, { clientY: 400 }); - expect(uiStore.bottomPanelHeight).not.toBe(startHeight); + expect(uiStore.bottomPanelRatio).toBeGreaterThan(startRatio); await fireEvent.mouseUp(window); }); }); diff --git a/webview-ui/src/components/commit/CommitDetails.svelte b/webview-ui/src/components/commit/CommitDetails.svelte index a9eb9b4..8463ab8 100644 --- a/webview-ui/src/components/commit/CommitDetails.svelte +++ b/webview-ui/src/components/commit/CommitDetails.svelte @@ -178,12 +178,12 @@ // passed to FileDiffView and the tree's "Reverse File" action. const canReverseInThisView = $derived(!!commit && stashIndex === null); - let filesPanelWidth = $state(240); + let filesPanelWidth = $state(360); let isResizing = $state(false); let resizeStartX = 0; let resizeStartWidth = 0; // svelte-ignore state_referenced_locally - let activeTab = $state<'commit' | 'changes'>(commit ? 'commit' : 'changes'); + let activeTab = $state<'commit' | 'changes'>('changes'); let uncommittedTab = $state<'staged' | 'unstaged'>('staged'); let activeHash = $state(''); @@ -264,6 +264,7 @@ activeTab = 'changes'; vscode.postMessage({ type: 'getUncommittedDiff' }); } else if (hash) { + activeTab = 'changes'; vscode.postMessage({ type: 'getCommitDiff', payload: { hash } }); vscode.postMessage({ type: 'getLfsFiles' }); vscode.postMessage({ type: 'getCommitSignature', payload: { hash } }); @@ -460,7 +461,28 @@ return nodes; } - return sortTree(root.children); + function compactDirectoryChains(nodes: FileTreeNode[]): FileTreeNode[] { + return nodes.map((node) => { + if (node.isFile) return node; + + const names = [node.name]; + let current = node; + while (true) { + const onlyChild = current.children.length === 1 ? current.children[0] : undefined; + if (!onlyChild || onlyChild.isFile) break; + current = onlyChild; + names.push(current.name); + } + + return { + ...current, + name: names.join('/'), + children: compactDirectoryChains(current.children), + }; + }); + } + + return compactDirectoryChains(sortTree(root.children)); } // All changed-file paths under a tree node (the node itself if it's a file). diff --git a/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts b/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts index 9b04ec1..d761050 100644 --- a/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts +++ b/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts @@ -51,6 +51,14 @@ function deliverSignature(hash: string, signature: { status: 'good' | 'none' | ' })); } +async function openCommitTab(container: HTMLElement): Promise { + const commitTab = Array.from(container.querySelectorAll('.top-tab')) + .find(tab => /commit/i.test(tab.textContent ?? '')); + expect(commitTab).toBeTruthy(); + await fireEvent.click(commitTab!); + return commitTab!; +} + beforeEach(() => { i18n.setLocale('en'); globalThis.__postedMessages = []; @@ -116,6 +124,7 @@ describe('CommitDetails — request flow', () => { describe('CommitDetails — signature', () => { it('shows the verified signer (enriched with the committer name) and key ID', async () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.person-name')); deliverSignature('h1', { status: 'good', signer: 'Alice', keyId: 'ABCD1234' }); await waitFor(() => expect(container.querySelector('.sig-detail')).toBeTruthy()); @@ -128,6 +137,7 @@ describe('CommitDetails — signature', () => { it('labels an SSH key fingerprint as "SSH Key" instead of "GPG Key ID"', async () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.person-name')); deliverSignature('h1', { status: 'good', signer: 'a@x.com', keyId: 'SHA256:AbCdEf' }); await waitFor(() => expect(container.querySelector('.sig-detail')).toBeTruthy()); @@ -138,6 +148,7 @@ describe('CommitDetails — signature', () => { it('does not fabricate a signer from the committer for an unverified signature', async () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.person-name')); // git returns no %GS for an untrusted key — must not claim "Signed by ". deliverSignature('h1', { status: 'unverified', keyId: 'SHA256:UnTrust' }); @@ -150,6 +161,7 @@ describe('CommitDetails — signature', () => { it('shows no signature row or glyph for an unsigned commit', async () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.person-name')); deliverSignature('h1', { status: 'none' }); await Promise.resolve(); @@ -159,6 +171,7 @@ describe('CommitDetails — signature', () => { it('ignores a stale signature response for a different commit', async () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.person-name')); deliverSignature('other-hash', { status: 'good', signer: 'Mallory' }); await Promise.resolve(); @@ -170,6 +183,7 @@ describe('CommitDetails — signature', () => { describe('CommitDetails — commit info rendering', () => { it('renders author name, email, and short hash', async () => { const { container } = render(CommitDetails, { commit: commit() }); + await openCommitTab(container); await waitFor(() => container.querySelector('.person-name')); const text = container.textContent ?? ''; expect(text).toContain('Alice'); @@ -177,19 +191,21 @@ describe('CommitDetails — commit info rendering', () => { expect(text).toContain('abcdef1'); // short hash }); - it('omits the committer column when committer matches author', () => { + it('omits the committer column when committer matches author', async () => { const { container } = render(CommitDetails, { commit: commit() }); + await openCommitTab(container); const labels = Array.from(container.querySelectorAll('.info-label')).map(el => el.textContent?.trim()); expect(labels).toContain('Author'); expect(labels).not.toContain('Committer'); }); - it('shows the committer column when committer differs from author', () => { + it('shows the committer column when committer differs from author', async () => { const { container } = render(CommitDetails, { commit: commit({ committer: { name: 'Bob', email: 'b@x.com', date: '2024-01-15T11:00:00Z' }, }), }); + await openCommitTab(container); const labels = Array.from(container.querySelectorAll('.info-label')).map(el => el.textContent?.trim()); expect(labels).toContain('Committer'); }); @@ -219,14 +235,15 @@ describe('CommitDetails — files list', () => { }); describe('CommitDetails — tabs', () => { - it('default tab is "commit" when a real commit is selected', () => { + it('default tab is "changes" when a real commit is selected', () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) }); const activeTab = container.querySelector('.top-tab.active')?.textContent ?? ''; - expect(activeTab.toLowerCase()).toContain('commit'); + expect(activeTab.toLowerCase()).toContain('changes'); }); it('clicking Changes tab activates it', async () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) }); + await openCommitTab(container); const tabs = container.querySelectorAll('.top-tab'); const changesTab = Array.from(tabs).find(t => /change/i.test(t.textContent ?? ''))!; await fireEvent.click(changesTab); @@ -305,6 +322,7 @@ describe('CommitDetails — empty / compare', () => { describe('CommitDetails — SHA copy buttons', () => { it('full SHA copy button posts copyToClipboard with the full hash', async () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'abcdef1234567890', abbreviatedHash: 'abcdef1' }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.copy-btns')); const btns = container.querySelectorAll('.copy-btn'); globalThis.__postedMessages = []; @@ -317,6 +335,7 @@ describe('CommitDetails — SHA copy buttons', () => { it('short SHA copy button posts copyToClipboard with the abbreviated hash', async () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'abcdef1234567890', abbreviatedHash: 'abcdef1' }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.copy-btns')); const btns = container.querySelectorAll('.copy-btn'); globalThis.__postedMessages = []; @@ -331,12 +350,14 @@ describe('CommitDetails — SHA copy buttons', () => { describe('CommitDetails — parent links', () => { it('renders one parent-link button per parent', async () => { const { container } = render(CommitDetails, { commit: commit({ parents: ['p1', 'p2'] }) }); + await openCommitTab(container); await waitFor(() => container.querySelectorAll('.parent-link').length === 2); expect(container.querySelectorAll('.parent-link').length).toBe(2); }); it('clicking a parent updates uiStore.selectedCommitHash and posts searchByHash', async () => { const { container } = render(CommitDetails, { commit: commit({ parents: ['parentHash1', 'parentHash2'] }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.parent-link')); globalThis.__postedMessages = []; await fireEvent.click(container.querySelector('.parent-link')!); @@ -346,15 +367,16 @@ describe('CommitDetails — parent links', () => { )).toBe(true); }); - it('omits the PARENTS row when commit has no parents', () => { + it('omits the PARENTS row when commit has no parents', async () => { const { container } = render(CommitDetails, { commit: commit({ parents: [] }) }); + await openCommitTab(container); const labels = Array.from(container.querySelectorAll('.meta-label')).map(el => el.textContent?.trim()); expect(labels).not.toContain('Parents'); }); }); describe('CommitDetails — refs rendering', () => { - it('renders REFS row with branch and tag badges', () => { + it('renders REFS row with branch and tag badges', async () => { const { container } = render(CommitDetails, { commit: commit({ refs: [ @@ -363,12 +385,13 @@ describe('CommitDetails — refs rendering', () => { ], }), }); + await openCommitTab(container); const text = container.querySelector('.meta-value')?.textContent ?? ''; expect(text).toMatch(/main/); expect(text).toMatch(/v1\.0/); }); - it('applies badge-head to the current branch and badge-fixed to tags', () => { + it('applies badge-head to the current branch and badge-fixed to tags', async () => { const { container } = render(CommitDetails, { commit: commit({ refs: [ @@ -378,6 +401,7 @@ describe('CommitDetails — refs rendering', () => { ], }), }); + await openCommitTab(container); const badges = Array.from(container.querySelectorAll('.ref-badge')); const byText = (txt: string) => badges.find(b => (b.textContent ?? '').includes(txt))!; expect(byText('main').classList.contains('badge-head')).toBe(true); @@ -388,7 +412,7 @@ describe('CommitDetails — refs rendering', () => { expect(byText('v1.0').classList.contains('badge-head')).toBe(false); }); - it('hides REFS row when only stash or remote HEAD refs exist', () => { + it('hides REFS row when only stash or remote HEAD refs exist', async () => { const { container } = render(CommitDetails, { commit: commit({ refs: [ @@ -397,6 +421,7 @@ describe('CommitDetails — refs rendering', () => { ], }), }); + await openCommitTab(container); const labels = Array.from(container.querySelectorAll('.meta-label')).map(el => el.textContent?.trim()); expect(labels).not.toContain('REFS'); }); @@ -715,6 +740,7 @@ describe('CommitDetails — parent hover preview', () => { it('mouseenter on a parent posts getCommitData when not cached', async () => { vi.useFakeTimers(); const { container } = render(CommitDetails, { commit: commit({ parents: ['parent1'] }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.parent-link')); globalThis.__postedMessages = []; await fireEvent.mouseEnter(container.querySelector('.parent-link')!, { clientX: 100, clientY: 100 }); @@ -731,6 +757,7 @@ describe('CommitDetails — parent hover preview', () => { vi.useFakeTimers(); commitStore.commits = [commit({ hash: 'parent1', subject: 'parent commit' })]; const { container } = render(CommitDetails, { commit: commit({ parents: ['parent1'] }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.parent-link')); globalThis.__postedMessages = []; await fireEvent.mouseEnter(container.querySelector('.parent-link')!, { clientX: 50, clientY: 50 }); @@ -745,6 +772,7 @@ describe('CommitDetails — parent hover preview', () => { it('mouseleave before delay cancels the preview', async () => { vi.useFakeTimers(); const { container } = render(CommitDetails, { commit: commit({ parents: ['parent1'] }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.parent-link')); const link = container.querySelector('.parent-link')!; await fireEvent.mouseEnter(link, { clientX: 100, clientY: 100 }); @@ -764,6 +792,7 @@ describe('CommitDetails — hover preview cache & navigate', () => { it('commitData message stores the commit in the preview cache and shows preview if hovering', async () => { vi.useFakeTimers(); const { container } = render(CommitDetails, { commit: commit({ parents: ['parent1'] }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.parent-link')); // Hover to set hoveredHash + previewPos await fireEvent.mouseEnter(container.querySelector('.parent-link')!, { clientX: 100, clientY: 100 }); @@ -784,6 +813,7 @@ describe('CommitDetails — hover preview cache & navigate', () => { vi.useFakeTimers(); commitStore.commits = [commit({ hash: 'parent1', subject: 'parent commit' })]; const { container } = render(CommitDetails, { commit: commit({ parents: ['parent1'] }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.parent-link')); // Cached commit → preview shows directly after delay await fireEvent.mouseEnter(container.querySelector('.parent-link')!, { clientX: 100, clientY: 100 }); @@ -1329,10 +1359,11 @@ describe('CommitDetails — markdown toggle', () => { i18n.setLocale('en'); }); - it('renders markdown by default when the message has markdown (bold subject becomes )', () => { + it('renders markdown by default when the message has markdown (bold subject becomes )', async () => { const { container } = render(CommitDetails, { commit: commit({ subject: '**bold** subject', body: '- one\n- two', parents: [] }), }); + await openCommitTab(container); expect(container.querySelector('.message-section strong')?.textContent).toBe('bold'); }); @@ -1340,6 +1371,7 @@ describe('CommitDetails — markdown toggle', () => { const { container, getByText } = render(CommitDetails, { commit: commit({ subject: '**bold** subject', body: '- one\n- two', parents: [] }), }); + await openCommitTab(container); await fireEvent.click(getByText('Plain Text')); expect(container.querySelector('.message-section strong')).toBeNull(); expect(container.querySelector('.message-section')?.textContent).toContain('**bold**'); @@ -1349,15 +1381,17 @@ describe('CommitDetails — markdown toggle', () => { const { container, getByText } = render(CommitDetails, { commit: commit({ subject: '**bold** subject', body: '- one\n- two', parents: [] }), }); + await openCommitTab(container); await fireEvent.click(getByText('Plain Text')); await fireEvent.click(getByText('Markdown')); expect(container.querySelector('.message-section strong')?.textContent).toBe('bold'); }); - it('hides the toggle and shows plain text when the message has no markdown', () => { + it('hides the toggle and shows plain text when the message has no markdown', async () => { const { container, queryByText } = render(CommitDetails, { commit: commit({ subject: 'Fix crash on startup', body: 'No markdown here.', parents: [] }), }); + await openCommitTab(container); expect(container.querySelector('.message-view-toggle')).toBeNull(); expect(queryByText('Markdown')).toBeNull(); expect(container.querySelector('.message-subject')?.textContent).toContain('Fix crash on startup'); diff --git a/webview-ui/src/components/modals/FlowFinishModal.svelte b/webview-ui/src/components/modals/FlowFinishModal.svelte index 1222f38..0913edc 100644 --- a/webview-ui/src/components/modals/FlowFinishModal.svelte +++ b/webview-ui/src/components/modals/FlowFinishModal.svelte @@ -68,7 +68,7 @@
{i + 1} - {@html step.text} + {@html step.text}
{/each}
@@ -90,6 +90,10 @@ margin: 12px 0; } + .modal-context-card { + flex-wrap: wrap; + } + .flow-step { display: flex; align-items: center; @@ -119,6 +123,21 @@ flex-shrink: 0; } + .flow-step-content { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + flex-wrap: wrap; + min-width: 0; + } + + .modal-context-card :global(.modal-pill), + .flow-step :global(.modal-pill) { + max-width: 100%; + flex-shrink: 0; + } + .danger { background: var(--vscode-errorForeground, #f44336) !important; } diff --git a/webview-ui/src/lib/stores/ui.svelte.ts b/webview-ui/src/lib/stores/ui.svelte.ts index 7df2ff1..8535cc7 100644 --- a/webview-ui/src/lib/stores/ui.svelte.ts +++ b/webview-ui/src/lib/stores/ui.svelte.ts @@ -1,6 +1,6 @@ import type { InteractiveRebaseMode } from '../types'; -export const BOTTOM_PANEL_DEFAULT_RATIO = 0.35; +export const BOTTOM_PANEL_DEFAULT_RATIO = 0.5; export const BOTTOM_PANEL_MIN_RATIO = 0.2; export const BOTTOM_PANEL_MAX_RATIO = 0.7; @@ -14,7 +14,7 @@ class UiStore { compareRef1 = $state(null); compareRef2 = $state(null); viewMode = $state<'graph' | 'log' | 'stats'>('graph'); - bottomPanelHeight = $state(250); + bottomPanelRatio = $state(BOTTOM_PANEL_DEFAULT_RATIO); showBottomPanel = $state(true); sidebarWidth = $state(220); errorMessage = $state(null);