diff --git a/vscode/src/git/branchSync.ts b/vscode/src/git/branchSync.ts deleted file mode 100644 index 74ed5fa..0000000 --- a/vscode/src/git/branchSync.ts +++ /dev/null @@ -1,236 +0,0 @@ -/** - * Branch-sync helpers: fast-forward the remote auto-build branch up to the - * remote dev branch (no working-tree side effects). - * - * Drives the toolbar "branch sync" button. Caller is responsible for - * resolving an empty `autoBuildBranch` to fall back to `devBranch` *before* - * calling — these helpers treat the inputs as final. - * - * We shell out to the system `git` binary via `execFile` to match the rest - * of this codebase (`src/git/remote.ts`, `src/git/worktree.ts`); no working - * tree is touched (no checkout / no fetch into local refs that the user - * might be sitting on). - */ - -import { execFile } from 'node:child_process' - -export interface BranchSyncStatus { - /** - * Commits remote `autoBuildBranch` is behind remote `devBranch`. 0 when - * already in sync, or when `unavailable` (the field is still numeric so - * callers don't have to special-case it). - */ - behind: number - devBranch: string - autoBuildBranch: string - /** - * True when sync is structurally impossible (same branch, missing remote - * ref, fetch failed, not a git repo, …). Disables the toolbar button. - */ - unavailable?: boolean - /** Human-readable explanation, suitable for the button's tooltip. */ - reason?: string -} - -interface RunResult { - ok: boolean - stdout: string - stderr: string - code: string | number | undefined -} - -function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise { - return new Promise((resolve) => { - execFile( - 'git', - ['-C', cwd, ...args], - { timeout: timeoutMs, encoding: 'utf8' }, - (err, stdout, stderr) => { - if (err) { - const code = (err as NodeJS.ErrnoException).code ?? err.message - resolve({ ok: false, stdout: stdout ?? '', stderr: stderr ?? '', code }) - return - } - resolve({ ok: true, stdout: stdout ?? '', stderr: stderr ?? '', code: undefined }) - }, - ) - }) -} - -/** - * 通用 git fetch origin,best-effort。返回 ok 标志和 stderr 供调用方记日志。 - * 超时 30s。 - */ -export async function gitFetch(workspaceRoot: string): Promise<{ ok: boolean, stderr: string, stdout: string }> { - const r = await runGit(workspaceRoot, ['fetch', 'origin']) - return { ok: r.ok, stderr: r.stderr, stdout: r.stdout } -} - -/** - * 解析要清理的 feature 分支名。 - * state.branch 优先;缺省时回退 PR head.ref。永不回退到 main/master。 - */ -export function resolveFeatureBranch(opts: { - stateBranch?: string | null - prHeadRef?: string | null -}): string { - const fromState = (opts.stateBranch ?? '').trim() - if (fromState) - return fromState - const fromPr = (opts.prHeadRef ?? '').trim() - if (!fromPr || fromPr === 'main' || fromPr === 'master') - return '' - return fromPr -} - -/** - * 删除本地分支。分支不存在时视为已清理,避免重复完成流程产生噪音。 - * - * 完成列路径会先 `rm -rf` worktree 再 prune;若 prune 未生效,git 仍认为 - * 分支被 worktree 占用,`branch -D` 会失败。这里在失败时再 prune 一次并重试。 - * 同时清掉 `refs/remotes/origin/`,避免 UI 里仍显示远程残留。 - */ -export async function deleteLocalBranch(workspaceRoot: string, branch: string): Promise<{ ok: boolean, stdout: string, stderr: string }> { - const exists = await runGit(workspaceRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]) - if (!exists.ok) { - await pruneRemoteTrackingRef(workspaceRoot, branch) - return { ok: true, stdout: '', stderr: '' } - } - - let r = await runGit(workspaceRoot, ['branch', '-D', branch]) - if (!r.ok) { - await runGit(workspaceRoot, ['worktree', 'prune']) - r = await runGit(workspaceRoot, ['branch', '-D', branch]) - } - await pruneRemoteTrackingRef(workspaceRoot, branch) - return { ok: r.ok, stdout: r.stdout, stderr: r.stderr } -} - -async function pruneRemoteTrackingRef(workspaceRoot: string, branch: string): Promise { - const ref = `refs/remotes/origin/${branch}` - const exists = await runGit(workspaceRoot, ['show-ref', '--verify', '--quiet', ref]) - if (!exists.ok) - return - await runGit(workspaceRoot, ['update-ref', '-d', ref]) -} - -export interface CheckBranchSyncOpts { - workspaceRoot: string - /** Already non-empty (caller falls back to devBranch when autoBuild is ''). */ - devBranch: string - autoBuildBranch: string -} - -/** - * Returns how far the remote auto-build branch is behind the remote dev - * branch. Never throws — failures collapse into `unavailable: true` with a - * `reason` suitable for surfacing in a tooltip. - */ -export async function checkBranchSync(opts: CheckBranchSyncOpts): Promise { - const { workspaceRoot, devBranch, autoBuildBranch } = opts - - if (devBranch === autoBuildBranch) { - return { - behind: 0, - devBranch, - autoBuildBranch, - unavailable: true, - reason: '开发分支与自动化构建分支相同', - } - } - - // Bail out early if we're not inside a git work-tree at all — otherwise - // the fetch error message ("not a git repository") would land in the - // tooltip and look like a real failure. - const insideRepo = await runGit(workspaceRoot, ['rev-parse', '--is-inside-work-tree'], 5_000) - if (!insideRepo.ok) { - return { - behind: 0, - devBranch, - autoBuildBranch, - unavailable: true, - reason: '当前工作区不是 git 仓库', - } - } - - // Fetch *just* the two refs we care about. We don't `--prune` because we - // don't want to perturb other tracking branches the user may be looking at. - const fetched = await runGit(workspaceRoot, ['fetch', 'origin', devBranch, autoBuildBranch]) - if (!fetched.ok) { - const trimmed = fetched.stderr.trim() || String(fetched.code ?? 'unknown') - return { - behind: 0, - devBranch, - autoBuildBranch, - unavailable: true, - reason: `获取远端失败:${trimmed}`, - } - } - - const counted = await runGit(workspaceRoot, [ - 'rev-list', - '--count', - `origin/${autoBuildBranch}..origin/${devBranch}`, - ]) - if (!counted.ok) { - const trimmed = counted.stderr.trim() || String(counted.code ?? 'unknown') - return { - behind: 0, - devBranch, - autoBuildBranch, - unavailable: true, - reason: `无法对比分支:${trimmed}`, - } - } - - const behind = Number.parseInt(counted.stdout.trim(), 10) - if (!Number.isInteger(behind) || behind < 0) { - return { - behind: 0, - devBranch, - autoBuildBranch, - unavailable: true, - reason: '解析落后提交数失败', - } - } - - return { behind, devBranch, autoBuildBranch } -} - -export interface RunBranchSyncOpts { - workspaceRoot: string - devBranch: string - autoBuildBranch: string -} - -/** - * Fast-forward push the remote autoBuild branch up to remote dev. Throws on - * any git failure (caller surfaces as a toast). Never `--force`-pushes — - * if the push is rejected because autoBuild has diverged, the user has to - * sort it out manually. - */ -export async function runBranchSync(opts: RunBranchSyncOpts): Promise { - const { workspaceRoot, devBranch, autoBuildBranch } = opts - - // Re-fetch so the push uses the freshest remote dev SHA. Cheap; matches - // what checkBranchSync did, but the user may have clicked sync minutes - // later. - const fetched = await runGit(workspaceRoot, ['fetch', 'origin', devBranch, autoBuildBranch]) - if (!fetched.ok) { - const trimmed = fetched.stderr.trim() || String(fetched.code ?? 'unknown') - throw new Error(`git fetch 失败:${trimmed}`) - } - - // `git push origin :refs/heads/` — server-side fast-forward only. - // We push the *remote-tracking* ref (origin/) so we don't depend on - // any local branch checkout state. - const pushed = await runGit(workspaceRoot, [ - 'push', - 'origin', - `origin/${devBranch}:refs/heads/${autoBuildBranch}`, - ]) - if (!pushed.ok) { - const trimmed = pushed.stderr.trim() || pushed.stdout.trim() || String(pushed.code ?? 'unknown') - throw new Error(`git push 失败:${trimmed}`) - } -} diff --git a/vscode/src/git/branchSync.test.ts b/vscode/src/git/branches.test.ts similarity index 97% rename from vscode/src/git/branchSync.test.ts rename to vscode/src/git/branches.test.ts index 2bc0954..add4b45 100644 --- a/vscode/src/git/branchSync.test.ts +++ b/vscode/src/git/branches.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { deleteLocalBranch, resolveFeatureBranch } from './branchSync' +import { deleteLocalBranch, resolveFeatureBranch } from './branches' const tempDirs: string[] = [] @@ -16,7 +16,7 @@ afterEach(() => { }) function tempDir(): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'branch-sync-')) + const dir = mkdtempSync(path.join(os.tmpdir(), 'branches-')) tempDirs.push(dir) return dir } diff --git a/vscode/src/git/branches.ts b/vscode/src/git/branches.ts new file mode 100644 index 0000000..5bf3474 --- /dev/null +++ b/vscode/src/git/branches.ts @@ -0,0 +1,90 @@ +/** + * 通用分支助手:fetch、解析 feature 分支名、删本地分支与远端跟踪引用。 + * + * 走系统 `git` 二进制(`execFile`),与 `src/git/remote.ts`、`src/git/worktree.ts` 一致; + * 不碰用户当前工作树(不 checkout)。刻意不依赖 `vscode`,便于单测。 + */ + +import { execFile } from 'node:child_process' + +interface RunResult { + ok: boolean + stdout: string + stderr: string + code: string | number | undefined +} + +function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise { + return new Promise((resolve) => { + execFile( + 'git', + ['-C', cwd, ...args], + { timeout: timeoutMs, encoding: 'utf8' }, + (err, stdout, stderr) => { + if (err) { + const code = (err as NodeJS.ErrnoException).code ?? err.message + resolve({ ok: false, stdout: stdout ?? '', stderr: stderr ?? '', code }) + return + } + resolve({ ok: true, stdout: stdout ?? '', stderr: stderr ?? '', code: undefined }) + }, + ) + }) +} + +/** + * 通用 git fetch origin,best-effort。返回 ok 标志和 stderr 供调用方记日志。 + * 超时 30s。 + */ +export async function gitFetch(workspaceRoot: string): Promise<{ ok: boolean, stderr: string, stdout: string }> { + const r = await runGit(workspaceRoot, ['fetch', 'origin']) + return { ok: r.ok, stderr: r.stderr, stdout: r.stdout } +} + +/** + * 解析要清理的 feature 分支名。 + * state.branch 优先;缺省时回退 PR head.ref。永不回退到 main/master。 + */ +export function resolveFeatureBranch(opts: { + stateBranch?: string | null + prHeadRef?: string | null +}): string { + const fromState = (opts.stateBranch ?? '').trim() + if (fromState) + return fromState + const fromPr = (opts.prHeadRef ?? '').trim() + if (!fromPr || fromPr === 'main' || fromPr === 'master') + return '' + return fromPr +} + +/** + * 删除本地分支。分支不存在时视为已清理,避免重复完成流程产生噪音。 + * + * 完成列路径会先 `rm -rf` worktree 再 prune;若 prune 未生效,git 仍认为 + * 分支被 worktree 占用,`branch -D` 会失败。这里在失败时再 prune 一次并重试。 + * 同时清掉 `refs/remotes/origin/`,避免 UI 里仍显示远程残留。 + */ +export async function deleteLocalBranch(workspaceRoot: string, branch: string): Promise<{ ok: boolean, stdout: string, stderr: string }> { + const exists = await runGit(workspaceRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]) + if (!exists.ok) { + await pruneRemoteTrackingRef(workspaceRoot, branch) + return { ok: true, stdout: '', stderr: '' } + } + + let r = await runGit(workspaceRoot, ['branch', '-D', branch]) + if (!r.ok) { + await runGit(workspaceRoot, ['worktree', 'prune']) + r = await runGit(workspaceRoot, ['branch', '-D', branch]) + } + await pruneRemoteTrackingRef(workspaceRoot, branch) + return { ok: r.ok, stdout: r.stdout, stderr: r.stderr } +} + +async function pruneRemoteTrackingRef(workspaceRoot: string, branch: string): Promise { + const ref = `refs/remotes/origin/${branch}` + const exists = await runGit(workspaceRoot, ['show-ref', '--verify', '--quiet', ref]) + if (!exists.ok) + return + await runGit(workspaceRoot, ['update-ref', '-d', ref]) +} diff --git a/vscode/src/git/worktree.ts b/vscode/src/git/worktree.ts index 5e3ae69..2c59114 100644 --- a/vscode/src/git/worktree.ts +++ b/vscode/src/git/worktree.ts @@ -8,7 +8,7 @@ import { promises as fsp, readdirSync, readFileSync, readlinkSync } from 'node:f import * as os from 'node:os' import * as path from 'node:path' import process from 'node:process' -import { deleteLocalBranch } from './branchSync' +import { deleteLocalBranch } from './branches' /** * 把开头的 `~` 展开为用户 home 目录。execFile 不走 shell,传给它的命令/路径 diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 0aedbc9..8d6fd2d 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -32,6 +32,7 @@ import * as issues from './handlers/issues' import * as managedSessions from './handlers/managedSessions' import * as prFiles from './handlers/prFiles' import * as profileAssets from './handlers/profileAssets' +import * as release from './handlers/release' import * as sessionHandoff from './handlers/sessionHandoff' import * as sessions from './handlers/sessions' import * as settings from './handlers/settings' @@ -177,6 +178,10 @@ export class KanbanWebviewPanel { // internal: handler 模块访问 commitRunning = false + /** 发布按钮三个写动作(切发布 / 摘修复 / 部署)共用的互斥标志,运行中按钮禁用。 */ + // internal: handler 模块访问 + releaseRunning = false + /** * Whether the workspace git working tree currently has any uncommitted * changes (working tree, index, or untracked). Populated by @@ -506,12 +511,20 @@ export class KanbanWebviewPanel { terminals.handleCloseSessionTab(this, msg.issueNumber, msg.kind) return } - if (msg.type === 'branch-sync/check') { - void this.handleBranchSyncCheck() + if (msg.type === 'release/check') { + void this.handleReleaseStatus() return } - if (msg.type === 'branch-sync/run') { - void toolbar.handleBranchSyncRun(this) + if (msg.type === 'release/cut') { + void release.handleReleaseCut(this) + return + } + if (msg.type === 'release/cherry-pick') { + void release.handleReleaseCherryPick(this) + return + } + if (msg.type === 'release/deploy') { + void release.handleReleaseDeploy(this) return } if (msg.type === 'env-lock/check') { @@ -1015,16 +1028,12 @@ export class KanbanWebviewPanel { } /** - * Compute how far the remote auto-build branch is behind the remote dev - * branch, then push a `branch-sync/status` to the webview. Called on - * webview init, after `settings/save`, and after a successful sync. - * - * Empty `autoBuildBranch` setting means "use devBranch" — collapsing to - * the equal-branch case in `checkBranchSync`, which marks it unavailable. + * 计算当期发布点与未发布提交数,推 `release/status` 给 webview。 + * 触发时机:webview 就绪 / 用户刷新、`settings/save` 之后、收到 push webhook、每个发布动作前后。 */ // internal: handler 模块访问 - handleBranchSyncCheck(): Promise { - return toolbar.handleBranchSyncCheck(this) + handleReleaseStatus(): Promise { + return release.handleReleaseStatus(this) } /** diff --git a/vscode/src/panel/handlers/branchCleanup.ts b/vscode/src/panel/handlers/branchCleanup.ts index 6c7462b..f87cb01 100644 --- a/vscode/src/panel/handlers/branchCleanup.ts +++ b/vscode/src/panel/handlers/branchCleanup.ts @@ -1,4 +1,4 @@ -import { deleteLocalBranch } from '../../git/branchSync' +import { deleteLocalBranch } from '../../git/branches' import { deleteBranch } from '../../gitea/api' import { logger } from '../../logging/logger' @@ -12,7 +12,7 @@ import { logger } from '../../logging/logger' * - 全程 best-effort:远程 / 本地各自 try/catch,失败只记日志——分支清理从不回滚已完成的主流程 * (worktree 已删、PR 已合并)。branch 为空直接返回。 * - * 放在 handlers 层而非 `git/branchSync.ts`:后者刻意不依赖 `vscode`(有单测覆盖 + * 放在 handlers 层而非 `git/branches.ts`:后者刻意不依赖 `vscode`(有单测覆盖 * deleteLocalBranch),而本函数要用 logger(→ vscode)和 gitea API。 */ export async function cleanupFeatureBranch(opts: { diff --git a/vscode/src/panel/handlers/issues.ts b/vscode/src/panel/handlers/issues.ts index 63d6cfd..1351214 100644 --- a/vscode/src/panel/handlers/issues.ts +++ b/vscode/src/panel/handlers/issues.ts @@ -15,7 +15,7 @@ import { getBrainstormPrompt } from '../../cc/prompts' import { claudeProjectsRoot, installClaudeSession } from '../../cc/sessionBundle' import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher' import { spawnClaude } from '../../cc/spawnClaude' -import { gitFetch, resolveFeatureBranch } from '../../git/branchSync' +import { gitFetch, resolveFeatureBranch } from '../../git/branches' import { detectRepo } from '../../git/remote' import { killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree' import { diff --git a/vscode/src/panel/handlers/release.ts b/vscode/src/panel/handlers/release.ts new file mode 100644 index 0000000..9f5c900 --- /dev/null +++ b/vscode/src/panel/handlers/release.ts @@ -0,0 +1,167 @@ +/** + * 工具栏「发布」按钮的扩展侧 handler:切新发布分支、摘修复到当期发布分支、触发正式服部署。 + * + * git 逻辑在 `src/git/release.ts`,Jenkins 触发在 `src/jenkins/trigger.ts`;这里只负责 + * 读设置、弹 QuickPick、发 toast、维护 `panel.releaseRunning` 互斥。 + */ + +import type { KanbanWebviewPanel } from '../KanbanPanel' +import { window, workspace } from 'vscode' +import { gitFetch } from '../../git/branches' +import { + cherryPickToRelease, + cutRelease, + getReleaseStatus, + listReleaseBranches, + listUnreleasedCommits, + todayReleaseBranch, +} from '../../git/release' +import { triggerJenkinsBuild } from '../../jenkins/trigger' +import { getSettings } from '../../settings/store' +import { dismiss, toast } from './handoffFlow' + +export async function handleReleaseStatus(panel: KanbanWebviewPanel): Promise { + const root = workspace.workspaceFolders?.[0]?.uri.fsPath + const s = getSettings(panel.context) + const base = { + type: 'release/status' as const, + devBranch: s.devBranch, + nextBranch: todayReleaseBranch(s.releaseBranchPrefix), + running: panel.releaseRunning, + } + if (!root) { + panel.postMessage({ ...base, unreleased: 0, unavailable: '请先打开一个工作区文件夹' }) + return + } + const st = await getReleaseStatus({ cwd: root, devBranch: s.devBranch, prefix: s.releaseBranchPrefix }) + panel.postMessage({ ...base, current: st.current, unreleased: st.unreleased, unavailable: st.unavailable }) +} + +function requireRoot(): string { + const root = workspace.workspaceFolders?.[0]?.uri.fsPath + if (!root) + throw new Error('请先打开一个工作区文件夹') + return root +} + +/** 三个写动作共用:互斥 + 前后刷新状态 + 统一错误 toast */ +async function runReleaseAction(panel: KanbanWebviewPanel, fn: () => Promise): Promise { + if (panel.releaseRunning) + return + panel.releaseRunning = true + void handleReleaseStatus(panel) + try { + await fn() + } + catch (err) { + toast(panel, 'error', err instanceof Error ? err.message : String(err), { dismissOnTimer: 10_000 }) + } + finally { + panel.releaseRunning = false + void handleReleaseStatus(panel) + } +} + +async function deploy(panel: KanbanWebviewPanel, branch: string): Promise { + const s = getSettings(panel.context) + await triggerJenkinsBuild({ job: s.jenkinsJob, params: { REF: branch, REMOTE_HOST: s.jenkinsProdHost } }) + toast(panel, 'success', `已触发 Jenkins ${s.jenkinsJob}:REF=${branch} → ${s.jenkinsProdHost}`) +} + +export function handleReleaseCut(panel: KanbanWebviewPanel): Promise { + return runReleaseAction(panel, async () => { + const root = requireRoot() + const s = getSettings(panel.context) + const branch = todayReleaseBranch(s.releaseBranchPrefix) + const count = await cutRelease({ cwd: root, devBranch: s.devBranch, branch }) + toast(panel, 'success', `已切发布分支 ${branch}(${count} 个提交)`) + if (!s.jenkinsJob) + return + const pick = await window.showInformationMessage(`现在部署 ${branch} 到${s.jenkinsProdHost}?`, '部署') + if (pick === '部署') + await deploy(panel, branch) + }) +} + +interface CommitPickItem { label: string, description: string, sha: string } + +export function handleReleaseCherryPick(panel: KanbanWebviewPanel): Promise { + return runReleaseAction(panel, async () => { + const root = requireRoot() + const s = getSettings(panel.context) + const status = await getReleaseStatus({ cwd: root, devBranch: s.devBranch, prefix: s.releaseBranchPrefix }) + if (status.unavailable) + throw new Error(status.unavailable) + const current = status.current + if (!current) + throw new Error('尚无发布分支,先切新发布') + const commits = await listUnreleasedCommits({ cwd: root, devBranch: s.devBranch, current }) + if (commits.length === 0) { + toast(panel, 'info', '没有待摘的提交') + return + } + const picked = await pickCommits(current, commits.map(c => ({ + label: c.subject, + description: `${c.sha.slice(0, 7)} · ${c.author} · ${c.date}`, + sha: c.sha, + }))) + if (!picked || picked.length === 0) + return + // 列表是 git log 顺序(新→旧),cherry-pick 要按时间顺序(旧→新) + const order = new Map(commits.map((c, i) => [c.sha, i])) + const shas = [...picked].sort((a, b) => (order.get(b.sha) ?? 0) - (order.get(a.sha) ?? 0)).map(p => p.sha) + const spinner = toast(panel, 'info', `正在摘 ${shas.length} 个提交到 ${current}…`, { spinner: true, dismissOnTimer: 0 }) + try { + await cherryPickToRelease({ cwd: root, branch: current, shas }) + } + finally { + dismiss(panel, spinner) + } + toast(panel, 'success', `已摘 ${shas.length} 个提交到 ${current}`) + }) +} + +/** 多选 QuickPick;用户按 Esc 返回 undefined */ +function pickCommits(current: string, items: CommitPickItem[]): Promise { + return new Promise((resolve) => { + const qp = window.createQuickPick() + qp.canSelectMany = true + qp.title = `摘修复到 ${current}` + qp.placeholder = '勾选要摘到发布分支的提交' + qp.items = items + qp.onDidAccept(() => { + resolve([...qp.selectedItems]) + qp.hide() + }) + qp.onDidHide(() => { + resolve(undefined) + qp.dispose() + }) + qp.show() + }) +} + +export function handleReleaseDeploy(panel: KanbanWebviewPanel): Promise { + return runReleaseAction(panel, async () => { + const root = requireRoot() + const s = getSettings(panel.context) + if (!s.jenkinsJob) + throw new Error('未配置 Jenkins 任务名,请先在设置里填写') + await gitFetch(root) + const branches = await listReleaseBranches({ cwd: root, prefix: s.releaseBranchPrefix }) + if (branches.length === 0) + throw new Error('尚无发布分支') + const [current, ...older] = branches + const items = [ + { label: current, description: '当期' }, + ...older.map(label => ({ label, description: '回滚到此发布点' })), + ] + const pick = await window.showQuickPick(items, { + title: `部署到${s.jenkinsProdHost}`, + placeHolder: '选择要部署的发布分支(默认当期,选旧的即回滚)', + }) + if (!pick) + return + await deploy(panel, pick.label) + }) +} diff --git a/vscode/src/panel/handlers/settings.ts b/vscode/src/panel/handlers/settings.ts index 16aa037..9aa24ef 100644 --- a/vscode/src/panel/handlers/settings.ts +++ b/vscode/src/panel/handlers/settings.ts @@ -202,8 +202,8 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: { || trimmedYtToken !== '' if ((!keepExisting && oldToken !== trimmedToken) || youtrackChanged) await panel.loadAndPush() - // Branch-sync inputs may have changed — refresh the toolbar button. - void panel.handleBranchSyncCheck() + // 开发分支 / 发布前缀可能变了,刷新工具栏发布按钮。 + void panel.handleReleaseStatus() } export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Promise { diff --git a/vscode/src/panel/handlers/toolbar.ts b/vscode/src/panel/handlers/toolbar.ts index b96c810..2fe113f 100644 --- a/vscode/src/panel/handlers/toolbar.ts +++ b/vscode/src/panel/handlers/toolbar.ts @@ -7,10 +7,9 @@ import { resolveCommitProfileForRun } from '../../cc/commitProfile' import { resolveProfilePath } from '../../cc/profiles' import { spawnClaude } from '../../cc/spawnClaude' import { findEnvFiles, lockEnvFiles, unlockEnvFiles } from '../../files/envLock' -import { checkBranchSync, runBranchSync } from '../../git/branchSync' import { missingGitIdentityKeys, readGitIdentity } from '../../git/identity' import { logger } from '../../logging/logger' -import { getEffectiveCommitProfilePath, getSettings } from '../../settings/store' +import { getEffectiveCommitProfilePath } from '../../settings/store' import { makeNonce } from '../KanbanPanel' import { dismiss, toast } from './handoffFlow' @@ -147,88 +146,6 @@ function formatCommitDone(resultText: string): string { return `提交完成:${summary}` } -export async function handleBranchSyncCheck(panel: KanbanWebviewPanel): Promise { - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - const s = getSettings(panel.context) - const devBranch = s.devBranch - // Empty string in storage means "follow devBranch" — resolve to - // devBranch so the check naturally hits the "same branch" disabled - // branch. - const autoBuildBranch = s.autoBuildBranch.length > 0 ? s.autoBuildBranch : devBranch - - if (!workspaceRoot) { - panel.postMessage({ - type: 'branch-sync/status', - behind: 0, - devBranch, - autoBuildBranch, - unavailable: true, - reason: '请先打开一个工作区文件夹', - }) - return - } - - const status = await checkBranchSync({ workspaceRoot, devBranch, autoBuildBranch }) - panel.postMessage({ type: 'branch-sync/status', ...status }) -} - -export async function handleBranchSyncRun(panel: KanbanWebviewPanel): Promise { - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - const s = getSettings(panel.context) - const devBranch = s.devBranch - const autoBuildBranch = s.autoBuildBranch.length > 0 ? s.autoBuildBranch : devBranch - - if (!workspaceRoot) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '请先打开一个工作区文件夹', - dismissOnTimer: 5000, - }) - void handleBranchSyncCheck(panel) - return - } - - if (devBranch === autoBuildBranch) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'info', - message: '开发分支与自动化构建分支相同,无需同步', - dismissOnTimer: 5000, - }) - void handleBranchSyncCheck(panel) - return - } - - try { - await runBranchSync({ workspaceRoot, devBranch, autoBuildBranch }) - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'success', - message: `已同步 ${devBranch} → ${autoBuildBranch}`, - dismissOnTimer: 5000, - }) - } - catch (err) { - const message = err instanceof Error ? err.message : String(err) - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: `分支同步失败:${message}`, - dismissOnTimer: 8000, - }) - } - finally { - // Re-emit status regardless of success/failure so the button reflects - // the new behind count (0 on success, unchanged on failure). - void handleBranchSyncCheck(panel) - } -} - export async function handleEnvLockCheck(panel: KanbanWebviewPanel): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath // 用底层 get 不传默认值,能区分"从未设过"(undefined) 和"用户主动选 false"。 diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index ad657a1..3658c21 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -387,14 +387,14 @@ class WebhookCoordinator { if (event.kind === 'push') { const s = getSettings(this.ctx) - const autoBuild = s.autoBuildBranch.trim() || s.devBranch logger.add({ level: 'info', source: 'webhook', message: `收到 push 事件 branch=${event.branch}`, }) - if (event.branch === s.devBranch || event.branch === autoBuild) - void this.activePanel?.handleBranchSyncCheck() + // 推到开发分支或任一发布分支都会改变「未发布提交数」,刷新发布按钮。 + if (event.branch === s.devBranch || event.branch.startsWith(s.releaseBranchPrefix)) + void this.activePanel?.handleReleaseStatus() return } diff --git a/vscode/test/gitea/stateJson.test.ts b/vscode/test/gitea/stateJson.test.ts index 580becf..2b27e34 100644 --- a/vscode/test/gitea/stateJson.test.ts +++ b/vscode/test/gitea/stateJson.test.ts @@ -341,7 +341,7 @@ describe('deleteLocalBranch', () => { .mockImplementationOnce((_cmd, _args, _opts, cb) => { cb(new Error('missing remote'), '', '') }) - const { deleteLocalBranch } = await import('../../src/git/branchSync.js') + const { deleteLocalBranch } = await import('../../src/git/branches.js') const result = await deleteLocalBranch('/repo', 'feature/test') @@ -379,7 +379,7 @@ describe('deleteLocalBranch', () => { .mockImplementationOnce((_cmd, _args, _opts, cb) => { cb(new Error('missing remote'), '', '') }) - const { deleteLocalBranch } = await import('../../src/git/branchSync.js') + const { deleteLocalBranch } = await import('../../src/git/branches.js') const result = await deleteLocalBranch('/repo', 'feature/weird name;rm -rf')