🐛 fix(vscode): 实施启动改为复用已有 feature 分支
This commit is contained in:
+106
-29
@@ -1,9 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Thin wrapper around `git worktree add -b` for the 实施 flow.
|
* git worktree 的薄封装:实施流用 ensureWorktree 保证「这个 feature 有一个可用
|
||||||
*
|
* worktree」,不依赖 git 库,走用户机器上的系统 git。
|
||||||
* 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'
|
import { execFile } from 'node:child_process'
|
||||||
@@ -30,41 +27,121 @@ export function resolveWorktreePath(stored: string, workspaceRoot: string): stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WorktreeOpts {
|
export interface WorktreeOpts {
|
||||||
/** Absolute path to the main workspace root. */
|
|
||||||
workspaceRoot: string
|
workspaceRoot: string
|
||||||
/** Absolute path of the worktree to create. */
|
/** 模板 path;① 已有 live worktree 时返回值可能与此不同。 */
|
||||||
worktreePath: string
|
worktreePath: string
|
||||||
/** New branch name; e.g. `feature/abcd1234`. */
|
|
||||||
branch: string
|
branch: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function runGit(workspaceRoot: string, args: string[], failLabel?: string): Promise<string> {
|
||||||
* Create a new git worktree with a freshly-created branch off current HEAD.
|
return new Promise((resolve, reject) => {
|
||||||
* git -C <workspaceRoot> worktree add <worktreePath> -b <branch>
|
execFile('git', ['-C', workspaceRoot, ...args], { timeout: 30_000 }, (err, stdout, stderr) => {
|
||||||
*
|
|
||||||
* 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) {
|
if (err) {
|
||||||
// execFile sets `err.code` for non-zero exits; for spawn failures
|
// execFile: 非零退出是数字 code;git 不在 PATH 时是 'ENOENT'
|
||||||
// (e.g. git not on PATH) it's a string like 'ENOENT'.
|
|
||||||
const code = (err as NodeJS.ErrnoException).code ?? 'unknown'
|
const code = (err as NodeJS.ErrnoException).code ?? 'unknown'
|
||||||
const trimmed = (stderr ?? '').toString().trim() || err.message
|
const trimmed = (stderr ?? '').toString().trim() || err.message
|
||||||
reject(new Error(`git worktree add 失败 (${code}): ${trimmed}`))
|
const label = failLabel ?? `git ${args.join(' ')}`
|
||||||
|
reject(new Error(`${label} 失败 (${code}): ${trimmed}`))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resolve()
|
resolve((stdout ?? '').toString())
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(p: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fsp.stat(p)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorktreePorcelain(stdout: string): Array<{ path: string, branch: string | null, prunable: boolean }> {
|
||||||
|
const entries: Array<{ path: string, branch: string | null, prunable: boolean }> = []
|
||||||
|
let current: { path: string, branch: string | null, prunable: boolean } | null = null
|
||||||
|
const flush = (): void => {
|
||||||
|
if (current?.path)
|
||||||
|
entries.push(current)
|
||||||
|
current = null
|
||||||
|
}
|
||||||
|
for (const line of stdout.split('\n')) {
|
||||||
|
if (line.startsWith('worktree ')) {
|
||||||
|
flush()
|
||||||
|
current = { path: line.slice('worktree '.length), branch: null, prunable: false }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!current)
|
||||||
|
continue
|
||||||
|
if (line.startsWith('branch ')) {
|
||||||
|
const ref = line.slice('branch '.length)
|
||||||
|
current.branch = ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref
|
||||||
|
}
|
||||||
|
else if (line === 'detached') {
|
||||||
|
current.branch = null
|
||||||
|
}
|
||||||
|
else if (line === 'prunable' || line.startsWith('prunable ')) {
|
||||||
|
current.prunable = true
|
||||||
|
}
|
||||||
|
else if (line === '') {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findLiveWorktreeForBranch(workspaceRoot: string, branch: string): Promise<string | undefined> {
|
||||||
|
const stdout = await runGit(workspaceRoot, ['worktree', 'list', '--porcelain'])
|
||||||
|
for (const entry of parseWorktreePorcelain(stdout)) {
|
||||||
|
if (entry.prunable || entry.branch !== branch)
|
||||||
|
continue
|
||||||
|
if (await pathExists(entry.path))
|
||||||
|
return entry.path
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function localBranchExists(workspaceRoot: string, branch: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await runGit(workspaceRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保证这个 feature 有一个可用 worktree。优先复用 live,绝不删 feature 分支
|
||||||
|
* —— 拖到「进行中」是开始实施,不是销毁上次进度。
|
||||||
|
*/
|
||||||
|
export async function ensureWorktree(opts: WorktreeOpts): Promise<string> {
|
||||||
|
const { workspaceRoot, branch } = opts
|
||||||
|
const worktreePath = path.resolve(opts.worktreePath)
|
||||||
|
|
||||||
|
// ① 该 branch 已在某个 live worktree → 原路返回(path ≠ 模板也不搬,避免丢未提交改动)
|
||||||
|
const live = await findLiveWorktreeForBranch(workspaceRoot, branch)
|
||||||
|
if (live) {
|
||||||
|
console.log(`[superpowers] 复用已有 worktree ${live} (${branch})`)
|
||||||
|
return live
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 模板 path 被垃圾占着(存在,但不是本仓库该 branch 的 live worktree)
|
||||||
|
if (await pathExists(worktreePath)) {
|
||||||
|
killProcessesUsingWorktree(worktreePath)
|
||||||
|
await removeWorktreeDir(workspaceRoot, worktreePath)
|
||||||
|
console.log(`[superpowers] 已清模板路径垃圾 ${worktreePath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const branchExists = await localBranchExists(workspaceRoot, branch)
|
||||||
|
const addArgs = branchExists
|
||||||
|
? ['worktree', 'add', worktreePath, branch] // ③ 有分支无 live worktree → 不加 -b,挂上已有分支,保留 commit
|
||||||
|
: ['worktree', 'add', worktreePath, '-b', branch] // ④ 分支也不存在 → -b 新建
|
||||||
|
await runGit(workspaceRoot, addArgs, 'git worktree add')
|
||||||
|
return worktreePath
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { resolveProfilePath } from '../../cc/profiles'
|
|||||||
import { getBrainstormContinuePrompt, getImplementPlanPrompt } from '../../cc/prompts'
|
import { getBrainstormContinuePrompt, getImplementPlanPrompt } from '../../cc/prompts'
|
||||||
import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher'
|
import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher'
|
||||||
import { detectRepo } from '../../git/remote'
|
import { detectRepo } from '../../git/remote'
|
||||||
import { createWorktree, resolveWorktreePath } from '../../git/worktree'
|
import { ensureWorktree, resolveWorktreePath } from '../../git/worktree'
|
||||||
import { updateIssueAssignees } from '../../gitea/api'
|
import { updateIssueAssignees } from '../../gitea/api'
|
||||||
import { logger } from '../../logging/logger'
|
import { logger } from '../../logging/logger'
|
||||||
import { getSettings } from '../../settings/store'
|
import { getSettings } from '../../settings/store'
|
||||||
@@ -766,45 +766,7 @@ export async function handleImplement(
|
|||||||
// 留空时回退到默认 ~/Sources/worktree/<项目>/<slug>。
|
// 留空时回退到默认 ~/Sources/worktree/<项目>/<slug>。
|
||||||
const worktreeTemplate = getSettings(panel.context).worktreeDirectory
|
const worktreeTemplate = getSettings(panel.context).worktreeDirectory
|
||||||
|| '~/Sources/worktree/$project_name/$feature_name'
|
|| '~/Sources/worktree/$project_name/$feature_name'
|
||||||
const worktreePath = resolveWorktreeDir(worktreeTemplate, workspaceRoot, slug)
|
let worktreePath = resolveWorktreeDir(worktreeTemplate, workspaceRoot, slug)
|
||||||
|
|
||||||
// Pre-flight: refuse if either the directory or the branch already
|
|
||||||
// exists, since `git worktree add -b` would fail and we'd have to
|
|
||||||
// unwind partial state.
|
|
||||||
let worktreeExists = false
|
|
||||||
try {
|
|
||||||
await fsp.stat(worktreePath)
|
|
||||||
worktreeExists = true
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
// ENOENT — good.
|
|
||||||
}
|
|
||||||
let branchExists = false
|
|
||||||
try {
|
|
||||||
branchExists = await new Promise<boolean>((resolve) => {
|
|
||||||
execFile(
|
|
||||||
'git',
|
|
||||||
['-C', workspaceRoot, 'branch', '--list', branch],
|
|
||||||
{ timeout: 10_000 },
|
|
||||||
(err, stdout) => {
|
|
||||||
if (err) {
|
|
||||||
resolve(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resolve((stdout ?? '').trim().length > 0)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
branchExists = false
|
|
||||||
}
|
|
||||||
if (worktreeExists || branchExists) {
|
|
||||||
void window.showErrorMessage(
|
|
||||||
`feature ${slug} 的 worktree 或分支已存在,请先清理`,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 工单级 profilePath > 默认 fallback。工单的 profile 锁定可在工单详情面板里覆盖。
|
// 工单级 profilePath > 默认 fallback。工单的 profile 锁定可在工单详情面板里覆盖。
|
||||||
const effectiveProfilePath = panel.resolveImplementProfilePath(profilePath)
|
const effectiveProfilePath = panel.resolveImplementProfilePath(profilePath)
|
||||||
@@ -834,20 +796,19 @@ export async function handleImplement(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the worktree before anything else, so a worktree-add failure
|
// 先保证 worktree 可用:后面写 state / 开终端都依赖实际 path。
|
||||||
// doesn't leave half-written state behind.
|
|
||||||
try {
|
try {
|
||||||
// worktree 现在建在工作区外(~/Sources/worktree/<项目>/...),父目录可能不存在;
|
// worktree 现在建在工作区外(~/Sources/worktree/<项目>/...),父目录可能不存在;
|
||||||
// git worktree add 不会创建多层父目录,先补齐。
|
// git worktree add 不会创建多层父目录,先补齐。
|
||||||
await fsp.mkdir(path.dirname(worktreePath), { recursive: true })
|
await fsp.mkdir(path.dirname(worktreePath), { recursive: true })
|
||||||
await createWorktree({ workspaceRoot, worktreePath, branch })
|
worktreePath = await ensureWorktree({ workspaceRoot, worktreePath, branch })
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
logger.add({
|
logger.add({
|
||||||
level: 'error',
|
level: 'error',
|
||||||
source: 'implement',
|
source: 'implement',
|
||||||
message: 'git worktree add 失败',
|
message: 'ensureWorktree 失败',
|
||||||
details: message,
|
details: message,
|
||||||
})
|
})
|
||||||
void window.showErrorMessage(message)
|
void window.showErrorMessage(message)
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import { existsSync, promises as fsp, 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 { ensureWorktree } from '../../src/git/worktree'
|
||||||
|
|
||||||
|
interface Fixture {
|
||||||
|
root: string
|
||||||
|
repo: string
|
||||||
|
templatePath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitEnv: NodeJS.ProcessEnv = {
|
||||||
|
...process.env,
|
||||||
|
GIT_CONFIG_NOSYSTEM: '1',
|
||||||
|
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||||
|
GIT_TERMINAL_PROMPT: '0',
|
||||||
|
GIT_AUTHOR_NAME: 'ensure-test',
|
||||||
|
GIT_AUTHOR_EMAIL: 'ensure@test.local',
|
||||||
|
GIT_COMMITTER_NAME: 'ensure-test',
|
||||||
|
GIT_COMMITTER_EMAIL: 'ensure@test.local',
|
||||||
|
}
|
||||||
|
|
||||||
|
function git(cwd: string, args: string[]): string {
|
||||||
|
return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env: gitEnv }).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupRepo(): Promise<Fixture> {
|
||||||
|
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sw-ensure-'))
|
||||||
|
const repo = path.join(root, 'sw-ensure', 'repo')
|
||||||
|
await fsp.mkdir(repo, { recursive: true })
|
||||||
|
git(repo, ['init', '-b', 'main'])
|
||||||
|
git(repo, ['config', 'user.email', 'ensure@test.local'])
|
||||||
|
git(repo, ['config', 'user.name', 'ensure-test'])
|
||||||
|
writeFileSync(path.join(repo, 'README.md'), 'hello\n')
|
||||||
|
git(repo, ['add', 'README.md'])
|
||||||
|
git(repo, ['commit', '-m', 'init'])
|
||||||
|
const templatePath = path.join(root, 'sw-ensure', 'wt', 'feature-x')
|
||||||
|
await fsp.mkdir(path.dirname(templatePath), { recursive: true })
|
||||||
|
return { root, repo, templatePath }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup(fx: Fixture | undefined): Promise<void> {
|
||||||
|
if (!fx)
|
||||||
|
return
|
||||||
|
try {
|
||||||
|
const out = git(fx.repo, ['worktree', 'list', '--porcelain'])
|
||||||
|
const extras: string[] = []
|
||||||
|
for (const line of out.split('\n')) {
|
||||||
|
if (!line.startsWith('worktree '))
|
||||||
|
continue
|
||||||
|
const p = line.slice('worktree '.length)
|
||||||
|
if (path.resolve(p) !== path.resolve(fx.repo))
|
||||||
|
extras.push(p)
|
||||||
|
}
|
||||||
|
for (const p of extras) {
|
||||||
|
try {
|
||||||
|
git(fx.repo, ['worktree', 'remove', '--force', p])
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
rmSync(p, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// repo 可能已残,下面整棵 tmp 仍要删
|
||||||
|
}
|
||||||
|
rmSync(fx.root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ensureWorktree', () => {
|
||||||
|
let fx: Fixture | undefined
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await cleanup(fx)
|
||||||
|
fx = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
it('本地已有 feature 分支且无 worktree 时挂上该分支并保留 commit', async () => {
|
||||||
|
fx = await setupRepo()
|
||||||
|
git(fx.repo, ['checkout', '-b', 'feature/x'])
|
||||||
|
writeFileSync(path.join(fx.repo, 'README.md'), 'hello\nextra-1\n')
|
||||||
|
git(fx.repo, ['add', 'README.md'])
|
||||||
|
git(fx.repo, ['commit', '-m', 'extra 1'])
|
||||||
|
writeFileSync(path.join(fx.repo, 'README.md'), 'hello\nextra-1\nextra-2\n')
|
||||||
|
git(fx.repo, ['add', 'README.md'])
|
||||||
|
git(fx.repo, ['commit', '-m', 'extra 2'])
|
||||||
|
writeFileSync(path.join(fx.repo, 'README.md'), 'hello\nextra-1\nextra-2\nextra-3\n')
|
||||||
|
git(fx.repo, ['add', 'README.md'])
|
||||||
|
git(fx.repo, ['commit', '-m', 'extra 3'])
|
||||||
|
git(fx.repo, ['checkout', 'main'])
|
||||||
|
const ahead = git(fx.repo, ['log', '--oneline', 'main..feature/x'])
|
||||||
|
expect(ahead.split('\n').filter(Boolean).length).toBe(3)
|
||||||
|
|
||||||
|
const actual = await ensureWorktree({
|
||||||
|
workspaceRoot: fx.repo,
|
||||||
|
worktreePath: fx.templatePath,
|
||||||
|
branch: 'feature/x',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(path.resolve(actual)).toBe(path.resolve(fx.templatePath))
|
||||||
|
expect(git(actual, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x')
|
||||||
|
expect(git(fx.repo, ['log', '--oneline', 'main..feature/x'])).toBe(ahead)
|
||||||
|
expect(git(actual, ['rev-parse', 'HEAD'])).toBe(git(fx.repo, ['rev-parse', 'feature/x']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('分支和 worktree 都不存在时创建新分支 worktree', async () => {
|
||||||
|
fx = await setupRepo()
|
||||||
|
|
||||||
|
const actual = await ensureWorktree({
|
||||||
|
workspaceRoot: fx.repo,
|
||||||
|
worktreePath: fx.templatePath,
|
||||||
|
branch: 'feature/fresh',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(path.resolve(actual)).toBe(path.resolve(fx.templatePath))
|
||||||
|
expect(git(actual, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/fresh')
|
||||||
|
expect(git(fx.repo, ['show-ref', '--verify', '--quiet', 'refs/heads/feature/fresh'])).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('该 branch 已有 live worktree 时返回已有 path,不建第二个', async () => {
|
||||||
|
fx = await setupRepo()
|
||||||
|
git(fx.repo, ['branch', 'feature/x'])
|
||||||
|
const existing = path.join(fx.root, 'sw-ensure', 'wt', 'already-here')
|
||||||
|
await fsp.mkdir(path.dirname(existing), { recursive: true })
|
||||||
|
git(fx.repo, ['worktree', 'add', existing, 'feature/x'])
|
||||||
|
|
||||||
|
const actual = await ensureWorktree({
|
||||||
|
workspaceRoot: fx.repo,
|
||||||
|
worktreePath: fx.templatePath,
|
||||||
|
branch: 'feature/x',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(path.resolve(actual)).toBe(path.resolve(existing))
|
||||||
|
expect(existsSync(fx.templatePath)).toBe(false)
|
||||||
|
expect(git(existing, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x')
|
||||||
|
const listed = git(fx.repo, ['worktree', 'list', '--porcelain'])
|
||||||
|
const wtLines = listed.split('\n').filter(l => l.startsWith('worktree '))
|
||||||
|
expect(wtLines).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('模板 path 是垃圾目录且分支不存在时删掉后 add -b 成功', async () => {
|
||||||
|
fx = await setupRepo()
|
||||||
|
await fsp.mkdir(fx.templatePath, { recursive: true })
|
||||||
|
writeFileSync(path.join(fx.templatePath, 'junk.txt'), 'garbage')
|
||||||
|
|
||||||
|
const actual = await ensureWorktree({
|
||||||
|
workspaceRoot: fx.repo,
|
||||||
|
worktreePath: fx.templatePath,
|
||||||
|
branch: 'feature/fresh',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(path.resolve(actual)).toBe(path.resolve(fx.templatePath))
|
||||||
|
expect(git(actual, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/fresh')
|
||||||
|
expect(existsSync(path.join(fx.templatePath, 'junk.txt'))).toBe(false)
|
||||||
|
expect(existsSync(path.join(fx.templatePath, 'README.md'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无 leftover 时可重复调用,第二次返回同一 path', async () => {
|
||||||
|
fx = await setupRepo()
|
||||||
|
|
||||||
|
const first = await ensureWorktree({
|
||||||
|
workspaceRoot: fx.repo,
|
||||||
|
worktreePath: fx.templatePath,
|
||||||
|
branch: 'feature/x',
|
||||||
|
})
|
||||||
|
const second = await ensureWorktree({
|
||||||
|
workspaceRoot: fx.repo,
|
||||||
|
worktreePath: fx.templatePath,
|
||||||
|
branch: 'feature/x',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(path.resolve(second)).toBe(path.resolve(first))
|
||||||
|
expect(git(second, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x')
|
||||||
|
const wtLines = git(fx.repo, ['worktree', 'list', '--porcelain'])
|
||||||
|
.split('\n')
|
||||||
|
.filter(l => l.startsWith('worktree '))
|
||||||
|
expect(wtLines).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user