✨ feat(vscode): 发布按钮扩展侧 handler,删除分支同步
Claude-Session: https://claude.ai/code/session_01HTMgwaE3woqM5yvsHiuSAa
This commit is contained in:
1 parent
6f59b4f3fd
commit
b363e08ef9
12 files changed
+292
-345
No files matched your search
@@ -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<RunResult> {
|
|
||||||
return new Promise<RunResult>((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/<branch>`,避免 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<void> {
|
|
||||||
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<BranchSyncStatus> {
|
|
||||||
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<void> {
|
|
||||||
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 <src>:refs/heads/<dst>` — server-side fast-forward only.
|
|
||||||
// We push the *remote-tracking* ref (origin/<dev>) 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}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|||||||
import * as os from 'node:os'
|
import * as os from 'node:os'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import { deleteLocalBranch, resolveFeatureBranch } from './branchSync'
|
import { deleteLocalBranch, resolveFeatureBranch } from './branches'
|
||||||
|
|
||||||
const tempDirs: string[] = []
|
const tempDirs: string[] = []
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function tempDir(): string {
|
function tempDir(): string {
|
||||||
const dir = mkdtempSync(path.join(os.tmpdir(), 'branch-sync-'))
|
const dir = mkdtempSync(path.join(os.tmpdir(), 'branches-'))
|
||||||
tempDirs.push(dir)
|
tempDirs.push(dir)
|
||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
@@ -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<RunResult> {
|
||||||
|
return new Promise<RunResult>((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/<branch>`,避免 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<void> {
|
||||||
|
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])
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { promises as fsp, readdirSync, readFileSync, readlinkSync } from 'node:f
|
|||||||
import * as os from 'node:os'
|
import * as os from 'node:os'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import process from 'node:process'
|
import process from 'node:process'
|
||||||
import { deleteLocalBranch } from './branchSync'
|
import { deleteLocalBranch } from './branches'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把开头的 `~` 展开为用户 home 目录。execFile 不走 shell,传给它的命令/路径
|
* 把开头的 `~` 展开为用户 home 目录。execFile 不走 shell,传给它的命令/路径
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import * as issues from './handlers/issues'
|
|||||||
import * as managedSessions from './handlers/managedSessions'
|
import * as managedSessions from './handlers/managedSessions'
|
||||||
import * as prFiles from './handlers/prFiles'
|
import * as prFiles from './handlers/prFiles'
|
||||||
import * as profileAssets from './handlers/profileAssets'
|
import * as profileAssets from './handlers/profileAssets'
|
||||||
|
import * as release from './handlers/release'
|
||||||
import * as sessionHandoff from './handlers/sessionHandoff'
|
import * as sessionHandoff from './handlers/sessionHandoff'
|
||||||
import * as sessions from './handlers/sessions'
|
import * as sessions from './handlers/sessions'
|
||||||
import * as settings from './handlers/settings'
|
import * as settings from './handlers/settings'
|
||||||
@@ -177,6 +178,10 @@ export class KanbanWebviewPanel {
|
|||||||
// internal: handler 模块访问
|
// internal: handler 模块访问
|
||||||
commitRunning = false
|
commitRunning = false
|
||||||
|
|
||||||
|
/** 发布按钮三个写动作(切发布 / 摘修复 / 部署)共用的互斥标志,运行中按钮禁用。 */
|
||||||
|
// internal: handler 模块访问
|
||||||
|
releaseRunning = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the workspace git working tree currently has any uncommitted
|
* Whether the workspace git working tree currently has any uncommitted
|
||||||
* changes (working tree, index, or untracked). Populated by
|
* changes (working tree, index, or untracked). Populated by
|
||||||
@@ -506,12 +511,20 @@ export class KanbanWebviewPanel {
|
|||||||
terminals.handleCloseSessionTab(this, msg.issueNumber, msg.kind)
|
terminals.handleCloseSessionTab(this, msg.issueNumber, msg.kind)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (msg.type === 'branch-sync/check') {
|
if (msg.type === 'release/check') {
|
||||||
void this.handleBranchSyncCheck()
|
void this.handleReleaseStatus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (msg.type === 'branch-sync/run') {
|
if (msg.type === 'release/cut') {
|
||||||
void toolbar.handleBranchSyncRun(this)
|
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
|
return
|
||||||
}
|
}
|
||||||
if (msg.type === 'env-lock/check') {
|
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
|
* 计算当期发布点与未发布提交数,推 `release/status` 给 webview。
|
||||||
* branch, then push a `branch-sync/status` to the webview. Called on
|
* 触发时机:webview 就绪 / 用户刷新、`settings/save` 之后、收到 push webhook、每个发布动作前后。
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
// internal: handler 模块访问
|
// internal: handler 模块访问
|
||||||
handleBranchSyncCheck(): Promise<void> {
|
handleReleaseStatus(): Promise<void> {
|
||||||
return toolbar.handleBranchSyncCheck(this)
|
return release.handleReleaseStatus(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { deleteLocalBranch } from '../../git/branchSync'
|
import { deleteLocalBranch } from '../../git/branches'
|
||||||
import { deleteBranch } from '../../gitea/api'
|
import { deleteBranch } from '../../gitea/api'
|
||||||
import { logger } from '../../logging/logger'
|
import { logger } from '../../logging/logger'
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ import { logger } from '../../logging/logger'
|
|||||||
* - 全程 best-effort:远程 / 本地各自 try/catch,失败只记日志——分支清理从不回滚已完成的主流程
|
* - 全程 best-effort:远程 / 本地各自 try/catch,失败只记日志——分支清理从不回滚已完成的主流程
|
||||||
* (worktree 已删、PR 已合并)。branch 为空直接返回。
|
* (worktree 已删、PR 已合并)。branch 为空直接返回。
|
||||||
*
|
*
|
||||||
* 放在 handlers 层而非 `git/branchSync.ts`:后者刻意不依赖 `vscode`(有单测覆盖
|
* 放在 handlers 层而非 `git/branches.ts`:后者刻意不依赖 `vscode`(有单测覆盖
|
||||||
* deleteLocalBranch),而本函数要用 logger(→ vscode)和 gitea API。
|
* deleteLocalBranch),而本函数要用 logger(→ vscode)和 gitea API。
|
||||||
*/
|
*/
|
||||||
export async function cleanupFeatureBranch(opts: {
|
export async function cleanupFeatureBranch(opts: {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { getBrainstormPrompt } from '../../cc/prompts'
|
|||||||
import { claudeProjectsRoot, installClaudeSession } from '../../cc/sessionBundle'
|
import { claudeProjectsRoot, installClaudeSession } from '../../cc/sessionBundle'
|
||||||
import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher'
|
import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher'
|
||||||
import { spawnClaude } from '../../cc/spawnClaude'
|
import { spawnClaude } from '../../cc/spawnClaude'
|
||||||
import { gitFetch, resolveFeatureBranch } from '../../git/branchSync'
|
import { gitFetch, resolveFeatureBranch } from '../../git/branches'
|
||||||
import { detectRepo } from '../../git/remote'
|
import { detectRepo } from '../../git/remote'
|
||||||
import { killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree'
|
import { killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree'
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<void>): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<CommitPickItem[] | undefined> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const qp = window.createQuickPick<CommitPickItem>()
|
||||||
|
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<void> {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -202,8 +202,8 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
|
|||||||
|| trimmedYtToken !== ''
|
|| trimmedYtToken !== ''
|
||||||
if ((!keepExisting && oldToken !== trimmedToken) || youtrackChanged)
|
if ((!keepExisting && oldToken !== trimmedToken) || youtrackChanged)
|
||||||
await panel.loadAndPush()
|
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<void> {
|
export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Promise<void> {
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ import { resolveCommitProfileForRun } from '../../cc/commitProfile'
|
|||||||
import { resolveProfilePath } from '../../cc/profiles'
|
import { resolveProfilePath } from '../../cc/profiles'
|
||||||
import { spawnClaude } from '../../cc/spawnClaude'
|
import { spawnClaude } from '../../cc/spawnClaude'
|
||||||
import { findEnvFiles, lockEnvFiles, unlockEnvFiles } from '../../files/envLock'
|
import { findEnvFiles, lockEnvFiles, unlockEnvFiles } from '../../files/envLock'
|
||||||
import { checkBranchSync, runBranchSync } from '../../git/branchSync'
|
|
||||||
import { missingGitIdentityKeys, readGitIdentity } from '../../git/identity'
|
import { missingGitIdentityKeys, readGitIdentity } from '../../git/identity'
|
||||||
import { logger } from '../../logging/logger'
|
import { logger } from '../../logging/logger'
|
||||||
import { getEffectiveCommitProfilePath, getSettings } from '../../settings/store'
|
import { getEffectiveCommitProfilePath } from '../../settings/store'
|
||||||
import { makeNonce } from '../KanbanPanel'
|
import { makeNonce } from '../KanbanPanel'
|
||||||
import { dismiss, toast } from './handoffFlow'
|
import { dismiss, toast } from './handoffFlow'
|
||||||
|
|
||||||
@@ -147,88 +146,6 @@ function formatCommitDone(resultText: string): string {
|
|||||||
return `提交完成:${summary}`
|
return `提交完成:${summary}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleBranchSyncCheck(panel: KanbanWebviewPanel): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
export async function handleEnvLockCheck(panel: KanbanWebviewPanel): Promise<void> {
|
||||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||||
// 用底层 get 不传默认值,能区分"从未设过"(undefined) 和"用户主动选 false"。
|
// 用底层 get 不传默认值,能区分"从未设过"(undefined) 和"用户主动选 false"。
|
||||||
|
|||||||
@@ -387,14 +387,14 @@ class WebhookCoordinator {
|
|||||||
|
|
||||||
if (event.kind === 'push') {
|
if (event.kind === 'push') {
|
||||||
const s = getSettings(this.ctx)
|
const s = getSettings(this.ctx)
|
||||||
const autoBuild = s.autoBuildBranch.trim() || s.devBranch
|
|
||||||
logger.add({
|
logger.add({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
source: 'webhook',
|
source: 'webhook',
|
||||||
message: `收到 push 事件 branch=${event.branch}`,
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ describe('deleteLocalBranch', () => {
|
|||||||
.mockImplementationOnce((_cmd, _args, _opts, cb) => {
|
.mockImplementationOnce((_cmd, _args, _opts, cb) => {
|
||||||
cb(new Error('missing remote'), '', '')
|
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')
|
const result = await deleteLocalBranch('/repo', 'feature/test')
|
||||||
|
|
||||||
@@ -379,7 +379,7 @@ describe('deleteLocalBranch', () => {
|
|||||||
.mockImplementationOnce((_cmd, _args, _opts, cb) => {
|
.mockImplementationOnce((_cmd, _args, _opts, cb) => {
|
||||||
cb(new Error('missing remote'), '', '')
|
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')
|
const result = await deleteLocalBranch('/repo', 'feature/weird name;rm -rf')
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user