diff --git a/.changeset/fuzzy-base-branch-ref.md b/.changeset/fuzzy-base-branch-ref.md new file mode 100644 index 0000000..b0e1024 --- /dev/null +++ b/.changeset/fuzzy-base-branch-ref.md @@ -0,0 +1,5 @@ +--- +"@leesangb/wt": patch +--- + +Allow the Herdr base-branch picker to accept an exact existing Git ref even when fzf has no displayed match. diff --git a/README.ko.md b/README.ko.md index ca16968..78fb572 100644 --- a/README.ko.md +++ b/README.ko.md @@ -81,8 +81,9 @@ wt herdr install 새 worktree 액션은 새 브랜치에 일반 텍스트 입력을, base branch에 `fzf`를 사용합니다. Checkout은 로컬 브랜치를 보여주고, PR은 열린 GitHub PR의 제목, 작성자, head branch, Draft 상태를 보여줍니다. 어느 단계에서든 `Esc`를 누르면 -취소됩니다. `fzf`가 `PATH`에 있어야 하며 PR picker에는 인증된 GitHub CLI -(`gh`)가 필요합니다. +취소됩니다. base branch picker에서는 `origin/main`처럼 실제로 존재하는 ref를 +정확히 입력하면 표시된 항목과 매칭되지 않아도 선택됩니다. `fzf`가 `PATH`에 +있어야 하며 PR picker에는 인증된 GitHub CLI (`gh`)가 필요합니다. 새로 연 worktree Space는 연결된 Pull Request와 CI 상태를 자동으로 감시합니다. 확장된 Space 행에 메타데이터 토큰을 추가하세요: diff --git a/README.md b/README.md index a3b4c55..6ec73f5 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,10 @@ The plugin provides these actions: The new-worktree action uses a text prompt for the new branch and `fzf` for the base branch. Checkout lists local branches, while PR lists open GitHub pull requests with title, author, head branch, and draft status. Press `Esc` at any -step to cancel. The plugin requires `fzf` on `PATH`, and the PR picker requires -an authenticated GitHub CLI (`gh`). +step to cancel. In the base-branch picker, an exact existing ref such as +`origin/main` is accepted even when it does not match the displayed choices. +The plugin requires `fzf` on `PATH`, and the PR picker requires an authenticated +GitHub CLI (`gh`). Newly opened worktree Spaces automatically watch their linked pull request and CI status. Add the metadata tokens to the expanded Space rows: diff --git a/src/herdr-plugin/index.test.ts b/src/herdr-plugin/index.test.ts index 837b0cd..5cd0245 100644 --- a/src/herdr-plugin/index.test.ts +++ b/src/herdr-plugin/index.test.ts @@ -17,6 +17,7 @@ import { pullRequestRefreshIntervalMs, pullRequestStatusToken, refreshMetadataTtlMs, + resolveBaseBranchSelection, streamAndCaptureOutput, } from "./index.js"; @@ -73,6 +74,31 @@ describe("wt Herdr plugin", () => { ]); }); + test("accepts an exact existing base branch ref when fzf has no match", () => { + const refs = + "main\nfeature/local\norigin/HEAD\norigin/main\norigin/release/1.0\n"; + + expect(resolveBaseBranchSelection(refs, "origin/main\n", 1)).toBe( + "origin/main" + ); + expect( + resolveBaseBranchSelection(refs, "origin/release/1.0\n", 1) + ).toBe("origin/release/1.0"); + expect( + resolveBaseBranchSelection(refs, "origin/missing\n", 1) + ).toBeUndefined(); + expect( + resolveBaseBranchSelection( + refs, + "release/1.0\nrelease/1.0\torigin/release/1.0\n", + 0 + ) + ).toBe("origin/release/1.0"); + expect( + resolveBaseBranchSelection(refs, "origin/main\n", 130) + ).toBeUndefined(); + }); + test("formats pull requests with searchable metadata and a hidden number", () => { const [choice] = buildPullRequestChoices( JSON.stringify([ diff --git a/src/herdr-plugin/index.ts b/src/herdr-plugin/index.ts index 6eff3f5..79c534b 100644 --- a/src/herdr-plugin/index.ts +++ b/src/herdr-plugin/index.ts @@ -42,6 +42,11 @@ interface BranchRef { local: boolean; } +interface FzfResult { + output: string; + exitCode: number; +} + interface PullRequest { number: number; title: string; @@ -379,6 +384,32 @@ export function buildBaseBranchChoices(refs: string): BranchRef[] { return [...choices.values()].sort((a, b) => a.name.localeCompare(b.name)); } +export function resolveBaseBranchSelection( + refs: string, + output: string, + exitCode: number +): string | undefined { + const lines = output.trimEnd().split(/\r?\n/); + + if (exitCode === 0) { + return lines[1]?.split("\t")[1]; + } + + if (exitCode !== 1) return undefined; + + const query = lines[0]?.trim(); + if (!query) return undefined; + + const existingRefs = new Set( + refs + .split(/\r?\n/) + .map((ref) => ref.trim()) + .filter((ref) => ref && !ref.endsWith("/HEAD")) + ); + + return existingRefs.has(query) ? query : undefined; +} + function fitColumn(value: string, width: number): string { if (Bun.stringWidth(value) <= width) { return value + " ".repeat(width - Bun.stringWidth(value)); @@ -480,11 +511,11 @@ export function closedLinkedWorktrees(json: string): Array<{ .map(({ label, path }) => ({ label, path })); } -async function fzf( +async function runFzf( args: string[], input: string, cwd: string -): Promise { +): Promise { const proc = spawn(["fzf", ...args], { cwd, stdin: "pipe", @@ -497,6 +528,16 @@ async function fzf( const output = await new Response(proc.stdout).text(); const exitCode = await proc.exited; + return { output, exitCode }; +} + +async function fzf( + args: string[], + input: string, + cwd: string +): Promise { + const { output, exitCode } = await runFzf(args, input, cwd); + if (exitCode === 130 || exitCode === 1) return undefined; if (exitCode !== 0) throw new Error(`fzf exited with code ${exitCode}`); return output.trim(); @@ -560,18 +601,27 @@ async function pickBaseBranch(cwd: string): Promise { cwd ); const choices = buildBaseBranchChoices(refs); - const selected = await fzf( + const result = await runFzf( [ "--delimiter=\t", "--with-nth=1", + "--print-query", "--prompt=Base branch> ", - "--header=Enter: select · Esc: cancel", + "--header=Enter: select or accept exact ref · Esc: cancel", ], choices.map(({ name, ref }) => `${name}\t${ref}`).join("\n"), cwd ); - return selected?.split("\t")[1]; + if ( + result.exitCode !== 0 && + result.exitCode !== 1 && + result.exitCode !== 130 + ) { + throw new Error(`fzf exited with code ${result.exitCode}`); + } + + return resolveBaseBranchSelection(refs, result.output, result.exitCode); } async function pickLocalBranch(cwd: string): Promise {