This commit is contained in:
cruldra committed 2026-06-23 05:02:15 +08:00
commit e6f1776d4f
264 files changed
+54215

No files matched your search

+196
View File
@@ -0,0 +1,196 @@
/**
* 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 }
}
/**
* 删除本地分支。分支不存在时视为已清理,避免重复完成流程产生噪音。
*/
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)
return { ok: true, stdout: '', stderr: '' }
const r = await runGit(workspaceRoot, ['branch', '-D', branch])
return { ok: r.ok, stdout: r.stdout, stderr: r.stderr }
}
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}`)
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Detects the workspace's Gitea repo coordinates from `git remote get-url origin`.
*
* Supports both HTTPS forms (`https://gitea.x.cn/owner/repo.git`) and SSH
* forms (`git@gitea.x.cn:owner/repo.git`, with or without a leading
* `ssh://`). The pure URL parser is exported separately so it's unit-testable
* without spawning a child process.
*/
import { execFile } from 'node:child_process'
export interface Remote {
host: string
owner: string
repo: string
}
const HTTPS_RE = /^https?:\/\/(?:[^@]+@)?([^/:]+)(?::\d+)?\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/
const SSH_RE = /^(?:ssh:\/\/)?(?:[^@]+@)?([^/:]+)[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/
export function parseRemoteUrl(url: string): Remote | undefined {
const trimmed = url.trim()
if (!trimmed)
return undefined
const https = trimmed.match(HTTPS_RE)
if (https)
return { host: https[1], owner: https[2], repo: https[3] }
const ssh = trimmed.match(SSH_RE)
if (ssh)
return { host: ssh[1], owner: ssh[2], repo: ssh[3] }
return undefined
}
/**
* Resolves `{host, owner, repo}` for the origin remote of the given workspace
* root, or `undefined` if git fails or the URL is unparseable.
*/
export function detectRepo(workspaceRoot: string): Promise<Remote | undefined> {
return new Promise((resolve) => {
execFile(
'git',
['-C', workspaceRoot, 'remote', 'get-url', 'origin'],
{ encoding: 'utf8' },
(err, stdout) => {
if (err) {
resolve(undefined)
return
}
resolve(parseRemoteUrl(stdout))
},
)
})
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Thin wrapper around `git worktree add -b` for the 实施 flow.
*
* We invoke the system `git` binary via `execFile` rather than depend on a
* git library — keeps the surface minimal and lets the user benefit from
* whatever git they already have configured.
*/
import { execFile } from 'node:child_process'
export interface WorktreeOpts {
/** Absolute path to the main workspace root. */
workspaceRoot: string
/** Absolute path of the worktree to create. */
worktreePath: string
/** New branch name; e.g. `feature/abcd1234`. */
branch: string
}
/**
* Create a new git worktree with a freshly-created branch off current HEAD.
* git -C <workspaceRoot> worktree add <worktreePath> -b <branch>
*
* Rejects with a descriptive Error if git exits non-zero. The caller should
* surface stderr to the user via a toast.
*/
export async function createWorktree(opts: WorktreeOpts): Promise<void> {
const { workspaceRoot, worktreePath, branch } = opts
return new Promise<void>((resolve, reject) => {
execFile(
'git',
['-C', workspaceRoot, 'worktree', 'add', worktreePath, '-b', branch],
{ timeout: 30_000 },
(err, _stdout, stderr) => {
if (err) {
// execFile sets `err.code` for non-zero exits; for spawn failures
// (e.g. git not on PATH) it's a string like 'ENOENT'.
const code = (err as NodeJS.ErrnoException).code ?? 'unknown'
const trimmed = (stderr ?? '').toString().trim() || err.message
reject(new Error(`git worktree add 失败 (${code}): ${trimmed}`))
return
}
resolve()
},
)
})
}
+141
View File
@@ -0,0 +1,141 @@
/**
* Spawn user-provided shell scripts around the worktree create / remove
* lifecycle. Lets the user perform setup (copy `.env`, open IDE, …) and
* teardown (close IDE, archive logs, …) without modifying extension code.
*
* Behavior contract:
* - script absent on disk → silently return `skipped` (default state).
* - script exits 0 → `ok`.
* - non-zero exit / spawn ENOENT / timeout → returned as a non-`ok`
* status; the caller surfaces a `warn`-style toast but never aborts
* the surrounding flow.
*
* Env vars injected into the child process:
* - WORKTREE_PATH — absolute path of the worktree
* - WORKSPACE_ROOT — absolute path of the main workspace
* - BRANCH — feature branch name
* - ISSUE_NUMBER — gitea issue number (decimal string)
* - MAIN_BRANCH — settings.devBranch (default 'main')
*/
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
import * as path from 'node:path'
const DEFAULT_POST_CREATE_REL = '.spx/worktree-post-create.sh'
const DEFAULT_PRE_REMOVE_REL = '.spx/worktree-pre-remove.sh'
const DEFAULT_IMPL_TAB_PRE_CREATE_REL = '.spx/impl-tab-pre-create.sh'
const DEFAULT_IMPL_TAB_POST_CLOSE_REL = '.spx/impl-tab-post-close.sh'
const HOOK_TIMEOUT_MS = 30_000
export interface HookContext {
workspaceRoot: string
worktreePath: string
branch: string
issueNumber: number
mainBranch: string
/** User-configured override path. Empty / undefined → use the default
* `<workspaceRoot>/.spx/worktree-*.sh` location. */
customScriptPath?: string
}
export type HookStatus = 'skipped' | 'ok' | 'failed' | 'timeout' | 'enoent'
export interface HookResult {
status: HookStatus
exitCode?: number
stdout?: string
stderr?: string
errorMessage?: string
scriptPath?: string
}
function resolveScriptPath(ctx: HookContext, defaultRelPath: string): string {
const custom = (ctx.customScriptPath ?? '').trim()
if (custom.length > 0)
return path.isAbsolute(custom) ? custom : path.join(ctx.workspaceRoot, custom)
return path.join(ctx.workspaceRoot, defaultRelPath)
}
async function runHook(ctx: HookContext, defaultRelPath: string): Promise<HookResult> {
const scriptPath = resolveScriptPath(ctx, defaultRelPath)
if (!existsSync(scriptPath))
return { status: 'skipped', scriptPath }
return new Promise<HookResult>((resolve) => {
execFile(
'bash',
[scriptPath],
{
cwd: ctx.worktreePath,
env: {
...process.env,
WORKTREE_PATH: ctx.worktreePath,
WORKSPACE_ROOT: ctx.workspaceRoot,
BRANCH: ctx.branch,
ISSUE_NUMBER: String(ctx.issueNumber),
MAIN_BRANCH: ctx.mainBranch,
},
timeout: HOOK_TIMEOUT_MS,
},
(err, stdout, stderr) => {
if (!err) {
resolve({ status: 'ok', exitCode: 0, stdout, stderr, scriptPath })
return
}
const errCode = (err as NodeJS.ErrnoException).code
// ENOENT here means `bash` itself isn't on PATH (script presence
// was already checked above). Surface it distinctly so the warn
// toast can be informative.
if (errCode === 'ENOENT') {
resolve({
status: 'enoent',
errorMessage: 'bash 不存在(PATH 中找不到)',
stdout,
stderr,
scriptPath,
})
return
}
// Node's execFile reports timeout via `ETIMEDOUT` on err.code (and
// also kills the process with SIGTERM by default).
if (errCode === 'ETIMEDOUT' || (err as NodeJS.ErrnoException & { killed?: boolean }).killed) {
resolve({
status: 'timeout',
errorMessage: `脚本超时(${HOOK_TIMEOUT_MS / 1000}s)`,
stdout,
stderr,
scriptPath,
})
return
}
resolve({
status: 'failed',
exitCode: typeof (err as NodeJS.ErrnoException & { code?: number | string }).code === 'number'
? (err as unknown as { code: number }).code
: undefined,
errorMessage: err.message,
stdout,
stderr,
scriptPath,
})
},
)
})
}
export function runPostCreateHook(ctx: HookContext): Promise<HookResult> {
return runHook(ctx, DEFAULT_POST_CREATE_REL)
}
export function runPreRemoveHook(ctx: HookContext): Promise<HookResult> {
return runHook(ctx, DEFAULT_PRE_REMOVE_REL)
}
export function runImplTabPreCreateHook(ctx: HookContext): Promise<HookResult> {
return runHook(ctx, DEFAULT_IMPL_TAB_PRE_CREATE_REL)
}
export function runImplTabPostCloseHook(ctx: HookContext): Promise<HookResult> {
return runHook(ctx, DEFAULT_IMPL_TAB_POST_CLOSE_REL)
}