11
This commit is contained in:
commit
e6f1776d4f
264 files changed
+54215
No files matched your search
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user