diff --git a/src/app/repository-settings.ts b/src/app/repository-settings.ts new file mode 100644 index 0000000..9c67bed --- /dev/null +++ b/src/app/repository-settings.ts @@ -0,0 +1,100 @@ +import { isDeepStrictEqual } from "util"; +import { + mergeSettingsInputs, + normalizeSettings, + type WtSettings, + type WtSettingsInput, +} from "../domain/settings.js"; +import { listGitWorktreePaths } from "../infra/git/worktree-repository.js"; +import { loadSettingsInputs } from "../infra/storage/settings-store.js"; + +export interface RepositorySettings { + settings: WtSettings; + worktreeDirRoot: string; +} + +interface SettingsInputSource { + input?: WtSettingsInput; + root: string; +} + +async function resolveMainWorktreePath( + repoRoot: string +): Promise { + const worktrees = await listGitWorktreePaths(repoRoot); + return worktrees.find((worktree) => worktree.isMain)?.path; +} + +function selectWorktreeDirRoot( + sources: SettingsInputSource[], + fallbackRoot: string +): string { + return ( + sources.find((source) => source.input?.worktreeDir !== undefined)?.root ?? + fallbackRoot + ); +} + +function selectSharedSettingsSource( + currentSource: SettingsInputSource, + mainSource: SettingsInputSource +): SettingsInputSource { + if (!currentSource.input) { + return mainSource; + } + + if (!mainSource.input || !isDeepStrictEqual(currentSource.input, mainSource.input)) { + return currentSource; + } + + return mainSource; +} + +export async function loadRepositorySettings( + repoRoot: string +): Promise { + const mainWorktreePath = await resolveMainWorktreePath(repoRoot); + const settingsRoot = mainWorktreePath ?? repoRoot; + const currentInputs = await loadSettingsInputs(repoRoot); + const mainInputs = + settingsRoot === repoRoot + ? currentInputs + : await loadSettingsInputs(settingsRoot); + const currentSharedSource: SettingsInputSource = { + input: currentInputs.shared, + root: repoRoot, + }; + const mainSharedSource: SettingsInputSource = { + input: mainInputs.shared, + root: settingsRoot, + }; + const sharedSource = selectSharedSettingsSource( + currentSharedSource, + mainSharedSource + ); + const mainLocalSource: SettingsInputSource | undefined = + settingsRoot === repoRoot + ? undefined + : { input: mainInputs.local, root: settingsRoot }; + const currentLocalSource: SettingsInputSource = { + input: currentInputs.local, + root: repoRoot, + }; + const localSettings = mergeSettingsInputs( + mainLocalSource?.input, + currentLocalSource.input + ); + const settings = normalizeSettings( + mergeSettingsInputs(sharedSource.input, localSettings) + ); + const worktreeDirRoot = selectWorktreeDirRoot( + [ + currentLocalSource, + ...(mainLocalSource ? [mainLocalSource] : []), + sharedSource, + ], + sharedSource.root + ); + + return { settings, worktreeDirRoot }; +} diff --git a/src/app/use-cases/create-branch-worktree.ts b/src/app/use-cases/create-branch-worktree.ts index cf72d00..01088bb 100644 --- a/src/app/use-cases/create-branch-worktree.ts +++ b/src/app/use-cases/create-branch-worktree.ts @@ -10,9 +10,9 @@ import { } from "../worktree-creation.js"; import { copyConfiguredPaths } from "../worktree-copy.js"; import { requireRepositoryContext } from "../repository-context.js"; +import { loadRepositorySettings } from "../repository-settings.js"; import { loadWorktreeInfos } from "../worktree-catalog.js"; import { - loadSettings, resolveWorktreeDir, } from "../../infra/storage/settings-store.js"; import { @@ -62,7 +62,9 @@ export async function createBranchWorktree( ); } - const settings = await loadSettings(context.repoRoot); + const { settings, worktreeDirRoot } = await loadRepositorySettings( + context.repoRoot + ); const { id, idAdjustedFrom } = resolveUniqueWorktreeId( normalizedBranchName, worktrees.map((worktree) => worktree.id) @@ -70,7 +72,7 @@ export async function createBranchWorktree( const fullId = `${context.repoName}-${id}`; const worktreeBaseDir = resolveWorktreeDir( settings.worktreeDir, - context.repoRoot + worktreeDirRoot ); ensureWorktreeBaseDir(worktreeBaseDir); diff --git a/src/app/use-cases/create-pr-worktree.ts b/src/app/use-cases/create-pr-worktree.ts index 29ffc42..5b0de3e 100644 --- a/src/app/use-cases/create-pr-worktree.ts +++ b/src/app/use-cases/create-pr-worktree.ts @@ -10,9 +10,9 @@ import { } from "../worktree-creation.js"; import { copyConfiguredPaths } from "../worktree-copy.js"; import { requireRepositoryContext } from "../repository-context.js"; +import { loadRepositorySettings } from "../repository-settings.js"; import { loadWorktreeInfos } from "../worktree-catalog.js"; import { - loadSettings, resolveWorktreeDir, } from "../../infra/storage/settings-store.js"; import { @@ -111,7 +111,9 @@ export async function createPrWorktree( }; } - const settings = await loadSettings(context.repoRoot); + const { settings, worktreeDirRoot } = await loadRepositorySettings( + context.repoRoot + ); const { id, idAdjustedFrom } = resolveUniqueWorktreeId( preferredId, worktrees.map((worktree) => worktree.id) @@ -119,7 +121,7 @@ export async function createPrWorktree( const fullId = `${context.repoName}-${id}`; const worktreeBaseDir = resolveWorktreeDir( settings.worktreeDir, - context.repoRoot + worktreeDirRoot ); ensureWorktreeBaseDir(worktreeBaseDir); diff --git a/src/app/use-cases/create-worktree.ts b/src/app/use-cases/create-worktree.ts index 4bc7bbd..b563bcf 100644 --- a/src/app/use-cases/create-worktree.ts +++ b/src/app/use-cases/create-worktree.ts @@ -15,8 +15,8 @@ import { } from "../worktree-creation.js"; import { copyConfiguredPaths } from "../worktree-copy.js"; import { requireRepositoryContext } from "../repository-context.js"; +import { loadRepositorySettings } from "../repository-settings.js"; import { - loadSettings, resolveWorktreeDir, } from "../../infra/storage/settings-store.js"; import { @@ -89,14 +89,16 @@ export async function createWorktree( cwd: string = process.cwd() ): Promise { const context = await requireRepositoryContext(cwd); - const settings = await loadSettings(context.repoRoot); + const { settings, worktreeDirRoot } = await loadRepositorySettings( + context.repoRoot + ); const baseBranch = options.base ?? settings.baseBranch; const pushRemote = options.push ?? settings.pushRemote; const id = options.id ?? branchName; const fullId = `${context.repoName}-${id}`; const worktreeBaseDir = resolveWorktreeDir( settings.worktreeDir, - context.repoRoot + worktreeDirRoot ); ensureWorktreeBaseDir(worktreeBaseDir); diff --git a/src/app/use-cases/get-current-worktree.ts b/src/app/use-cases/get-current-worktree.ts new file mode 100644 index 0000000..711f5e1 --- /dev/null +++ b/src/app/use-cases/get-current-worktree.ts @@ -0,0 +1,84 @@ +import type { WtIssueSettings } from "../../domain/settings.js"; +import type { WorktreeInfo } from "../../domain/worktree.js"; +import { buildIssueLink } from "../../domain/issue-link.js"; +import { findPullRequestLinkForBranch } from "../../infra/github/cli.js"; +import { AppError } from "../errors.js"; +import { requireRepositoryContext } from "../repository-context.js"; +import { loadRepositorySettings } from "../repository-settings.js"; +import { loadCurrentWorktreeInfo } from "../worktree-catalog.js"; + +export interface GetCurrentWorktreeResult { + repoName: string; + worktree: WorktreeInfo; +} + +function enrichWorktreeWithIssueLink( + worktree: WorktreeInfo, + issueSettings: WtIssueSettings | undefined +): WorktreeInfo { + if (!issueSettings) { + return worktree; + } + + try { + const issueLink = buildIssueLink(worktree.branch, issueSettings); + + if (!issueLink) { + return worktree; + } + + return { + ...worktree, + issueKey: issueLink.key, + issueUrl: issueLink.url, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new AppError( + `Invalid issue.pattern "${issueSettings.pattern}": ${message}` + ); + } +} + +async function enrichWorktreeWithPullRequest( + repoRoot: string, + worktree: WorktreeInfo +): Promise { + if (worktree.prUrl || !worktree.branch || worktree.isDetached) { + return worktree; + } + + const pullRequest = await findPullRequestLinkForBranch( + repoRoot, + worktree.branch + ); + + if (!pullRequest) { + return worktree; + } + + return { + ...worktree, + prNumber: pullRequest.number, + prUrl: pullRequest.url, + }; +} + +export async function getCurrentWorktree( + cwd: string = process.cwd() +): Promise { + const context = await requireRepositoryContext(cwd); + const { settings } = await loadRepositorySettings(context.repoRoot); + const worktree = enrichWorktreeWithIssueLink( + await enrichWorktreeWithPullRequest( + context.repoRoot, + await loadCurrentWorktreeInfo(context) + ), + settings.issue + ); + + return { + repoName: context.repoName, + worktree, + }; +} diff --git a/src/app/use-cases/list-worktrees.ts b/src/app/use-cases/list-worktrees.ts index 4f2247f..ea1e6f4 100644 --- a/src/app/use-cases/list-worktrees.ts +++ b/src/app/use-cases/list-worktrees.ts @@ -7,12 +7,12 @@ import { requireRepositoryContext } from "../repository-context.js"; import { AppError } from "../errors.js"; import { buildIssueLinkFromPattern } from "../../domain/issue-link.js"; import { listPullRequestLinks } from "../../infra/github/cli.js"; -import { loadSettings } from "../../infra/storage/settings-store.js"; import { loadWorktreeInfos, loadWorktreeRemovalInfos, loadWorktreeStates, } from "../worktree-catalog.js"; +import { loadRepositorySettings } from "../repository-settings.js"; import type { WtIssueSettings } from "../../domain/settings.js"; export interface ListWorktreeInfosOptions { @@ -125,7 +125,7 @@ export async function listWorktrees( cwd: string = process.cwd() ): Promise { const context = await requireRepositoryContext(cwd); - const settings = await loadSettings(context.repoRoot); + const { settings } = await loadRepositorySettings(context.repoRoot); const worktreesWithIssueLinks = enrichWorktreesWithIssueLinks( settings.issue, await loadWorktreeStates(context) diff --git a/src/app/worktree-catalog.ts b/src/app/worktree-catalog.ts index 04c3e20..8c9dedc 100644 --- a/src/app/worktree-catalog.ts +++ b/src/app/worktree-catalog.ts @@ -1,7 +1,9 @@ import { statSync } from "fs"; import type { RepositoryContext } from "./repository-context.js"; +import { AppError } from "./errors.js"; import { buildWorktreeIdentifiers, + type GitWorktreeRef, type WorktreeInfo, type WorktreeMergeStatus, type WorktreeRemovalInfo, @@ -31,37 +33,55 @@ function resolveCreatedAt( } } +async function buildWorktreeInfo( + context: RepositoryContext, + worktree: GitWorktreeRef +): Promise { + const meta = await readWorktreeMeta(worktree.path); + const { id, fullId } = buildWorktreeIdentifiers( + context.repoName, + worktree.path, + meta + ); + + return { + id, + fullId, + path: worktree.path, + branch: worktree.branch, + isMain: worktree.isMain, + isDetached: worktree.isDetached, + head: worktree.head, + repoName: context.repoName, + createdAt: resolveCreatedAt(worktree.path, meta?.createdAt), + baseBranch: meta?.baseBranch, + baseCommit: meta?.baseCommit, + prNumber: meta?.prNumber, + prUrl: meta?.prUrl, + }; +} + export async function loadWorktreeInfos( context: RepositoryContext ): Promise { const gitWorktrees = await listGitWorktrees(context.repoRoot); - return Promise.all( - gitWorktrees.map(async (worktree) => { - const meta = await readWorktreeMeta(worktree.path); - const { id, fullId } = buildWorktreeIdentifiers( - context.repoName, - worktree.path, - meta - ); + return Promise.all(gitWorktrees.map((worktree) => buildWorktreeInfo(context, worktree))); +} - return { - id, - fullId, - path: worktree.path, - branch: worktree.branch, - isMain: worktree.isMain, - isDetached: worktree.isDetached, - head: worktree.head, - repoName: context.repoName, - createdAt: resolveCreatedAt(worktree.path, meta?.createdAt), - baseBranch: meta?.baseBranch, - baseCommit: meta?.baseCommit, - prNumber: meta?.prNumber, - prUrl: meta?.prUrl, - }; - }) +export async function loadCurrentWorktreeInfo( + context: RepositoryContext +): Promise { + const gitWorktrees = await listGitWorktrees(context.repoRoot); + const currentWorktree = gitWorktrees.find( + (worktree) => worktree.path === context.repoRoot ); + + if (!currentWorktree) { + throw new AppError("Current directory is not inside a git worktree"); + } + + return buildWorktreeInfo(context, currentWorktree); } export async function loadWorktreeStates( diff --git a/src/cli.e2e.test.ts b/src/cli.e2e.test.ts index 62b2bb8..d867733 100644 --- a/src/cli.e2e.test.ts +++ b/src/cli.e2e.test.ts @@ -295,6 +295,18 @@ function createFakeGithubEnv( " exit 1", "fi", 'if [ "$1" = "pr" ] && [ "${2:-}" = "list" ]; then', + ' target_head=""', + ' previous_arg=""', + ' for arg in "$@"; do', + ' if [ "$previous_arg" = "--head" ]; then', + ' target_head="$arg"', + " fi", + ' previous_arg="$arg"', + " done", + ` if [ -n "$target_head" ] && [ "$target_head" != "${headBranch}" ]; then`, + " printf '%s\\n' '[]'", + " exit 0", + " fi", ` printf '%s\\n' '[{"number":${prNumber},"url":"${prUrl}","headRefName":"${headBranch}"}]'`, " exit 0", "fi", @@ -1756,6 +1768,130 @@ describe("cli e2e", () => { expect(listResult.stdout).toContain(`PR: #${prNumber} ${prUrl}`); }); + test("shows the current linked worktree with a derived issue tracker URL", async () => { + const repo = await createTestRepo(); + const worktreeId = "dev-123"; + const branchName = "feature/DEV-123"; + + updateSettings(repo.repoRoot, settings => { + settings.issue = { + pattern: "[A-Z]+-\\d+", + url: "https://myissues.com/$issue", + }; + }); + runCli(["new", branchName, "--id", worktreeId, "--no-cd"], repo.repoRoot); + + const worktreePath = getWorktreePath(repo, worktreeId); + const nestedDir = join(worktreePath, "nested"); + mkdirSync(nestedDir, { recursive: true }); + + const result = runCliCapture(["current"], nestedDir); + assertProcessSuccess(result.status, result.stderr, result.stdout); + + expect(result.stdout).toContain(`ID: ${worktreeId} (current)`); + expect(result.stdout).toContain(`Branch: ${branchName}`); + expect(result.stdout).toContain( + "Issue: DEV-123 https://myissues.com/DEV-123" + ); + expect(result.stdout).toContain(`Path: ${realpathSync(worktreePath)}`); + }); + + test("shows stored PR metadata for the current worktree without querying GitHub again", async () => { + const repo = await createTestRepo(); + const prNumber = "790"; + const branchName = "feature-pr-current"; + const prUrl = "https://github.com/example/repo/pull/790"; + const ghLogPath = join(repo.repoRoot, "gh.log"); + + const createResult = runCliCapture(["pr", prNumber, "--no-cd"], repo.repoRoot, { + env: createFakeGithubEnv({ + headBranch: branchName, + logPath: ghLogPath, + prNumber, + prUrl, + }), + }); + assertProcessSuccess( + createResult.status, + createResult.stderr, + createResult.stdout + ); + + const worktreePath = getWorktreePath(repo, `pr-${prNumber}`); + const ghLogBefore = readFileSync(ghLogPath, "utf-8"); + const result = runCliCapture(["current"], worktreePath, { + env: createFakeGithubEnv({ + headBranch: branchName, + logPath: ghLogPath, + prNumber, + prUrl, + }), + }); + assertProcessSuccess(result.status, result.stderr, result.stdout); + + expect(result.stdout).toContain(`ID: pr-${prNumber} (current)`); + expect(result.stdout).toContain(`PR: #${prNumber} ${prUrl}`); + expect(readFileSync(ghLogPath, "utf-8")).toBe(ghLogBefore); + }); + + test("looks up PR metadata for the current branch worktree when none is stored", async () => { + const repo = await createTestRepo(); + const worktreeId = "pr-lookup"; + const branchName = "feature/pr-lookup"; + const prNumber = "791"; + const prUrl = "https://github.com/example/repo/pull/791"; + const ghLogPath = join(repo.repoRoot, "gh.log"); + + runCli(["new", branchName, "--id", worktreeId, "--no-cd"], repo.repoRoot); + + const worktreePath = getWorktreePath(repo, worktreeId); + const result = runCliCapture(["current"], worktreePath, { + env: createFakeGithubEnv({ + headBranch: branchName, + logPath: ghLogPath, + prNumber, + prUrl, + }), + }); + assertProcessSuccess(result.status, result.stderr, result.stdout); + + expect(result.stdout).toContain(`ID: ${worktreeId} (current)`); + expect(result.stdout).toContain(`PR: #${prNumber} ${prUrl}`); + expect(readFileSync(ghLogPath, "utf-8")).toContain( + `pr list --state open --head ${branchName} --json number,url` + ); + }); + + test("looks up numeric current branch names as PR heads instead of PR numbers", async () => { + const repo = await createTestRepo(); + const worktreeId = "numeric-branch"; + const branchName = "123"; + const prNumber = "792"; + const prUrl = "https://github.com/example/repo/pull/792"; + const ghLogPath = join(repo.repoRoot, "gh.log"); + + runCli(["new", branchName, "--id", worktreeId, "--no-cd"], repo.repoRoot); + + const worktreePath = getWorktreePath(repo, worktreeId); + const result = runCliCapture(["current"], worktreePath, { + env: createFakeGithubEnv({ + headBranch: branchName, + logPath: ghLogPath, + prNumber, + prUrl, + }), + }); + assertProcessSuccess(result.status, result.stderr, result.stdout); + + const ghLog = readFileSync(ghLogPath, "utf-8"); + + expect(result.stdout).toContain(`PR: #${prNumber} ${prUrl}`); + expect(ghLog).toContain( + `pr list --state open --head ${branchName} --json number,url` + ); + expect(ghLog).not.toContain(`pr view ${branchName}`); + }); + test("lists a detached main worktree instead of hiding it", async () => { const repo = await createGitRepo(); @@ -2852,4 +2988,138 @@ describe("cli e2e", () => { expect(result.stdout).toContain(`WT_PATH: ${resolvedWorktreePath}`); expect(result.stdout).toContain(`cd ${resolvedWorktreePath}`); }); + + test("resolves fallback relative worktreeDir from the main worktree", async () => { + const repo = await createTestRepo(); + const firstWorktreeId = "relative-source"; + const secondWorktreeId = "relative-target"; + const firstBranchName = "feature-relative-source"; + const secondBranchName = "feature-relative-target"; + const expectedWorktreeRoot = join(repo.repoRoot, "worktrees"); + const firstWorktreePath = join( + expectedWorktreeRoot, + `${repo.repoName}-${firstWorktreeId}` + ); + const secondWorktreePath = join( + expectedWorktreeRoot, + `${repo.repoName}-${secondWorktreeId}` + ); + + updateSettings(repo.repoRoot, settings => { + settings.worktreeDir = "./worktrees"; + }); + await $`git -C ${repo.repoRoot} add .wt/settings.json`.quiet(); + await $`git -C ${repo.repoRoot} commit -m track-settings`.quiet(); + runCli( + ["new", firstBranchName, "--id", firstWorktreeId, "--no-cd"], + repo.repoRoot + ); + + const result = runCliCapture( + ["new", secondBranchName, "--id", secondWorktreeId, "--no-cd"], + firstWorktreePath + ); + + assertProcessSuccess(result.status, result.stderr, result.stdout); + + const resolvedWorktreePath = realpathSync(secondWorktreePath); + + expect(existsSync(secondWorktreePath)).toBeTrue(); + expect(existsSync(join(secondWorktreePath, ".wt", "meta.json"))).toBeTrue(); + expect(existsSync(join(firstWorktreePath, "worktrees"))).toBeFalse(); + expect(result.stdout).toContain(`WT_PATH: ${resolvedWorktreePath}`); + expect(result.stdout).toContain(`cd ${resolvedWorktreePath}`); + }); + + test("resolves current local worktreeDir overrides from the linked worktree", async () => { + const repo = await createTestRepo(); + const firstWorktreeId = "relative-local-source"; + const secondWorktreeId = "relative-local-target"; + const firstBranchName = "feature-relative-local-source"; + const secondBranchName = "feature-relative-local-target"; + const mainWorktreeRoot = join(repo.repoRoot, "worktrees"); + const firstWorktreePath = join( + mainWorktreeRoot, + `${repo.repoName}-${firstWorktreeId}` + ); + const expectedWorktreeRoot = join(firstWorktreePath, "local-worktrees"); + const expectedWorktreePath = join( + expectedWorktreeRoot, + `${repo.repoName}-${secondWorktreeId}` + ); + + updateSettings(repo.repoRoot, settings => { + settings.worktreeDir = "./worktrees"; + }); + await $`git -C ${repo.repoRoot} add .wt/settings.json`.quiet(); + await $`git -C ${repo.repoRoot} commit -m track-settings`.quiet(); + runCli( + ["new", firstBranchName, "--id", firstWorktreeId, "--no-cd"], + repo.repoRoot + ); + writeFileSync( + join(firstWorktreePath, ".wt", "settings.local.json"), + JSON.stringify({ worktreeDir: "./local-worktrees" }, null, 2) + ); + + const result = runCliCapture( + ["new", secondBranchName, "--id", secondWorktreeId, "--no-cd"], + firstWorktreePath + ); + + assertProcessSuccess(result.status, result.stderr, result.stdout); + + const resolvedWorktreePath = realpathSync(expectedWorktreePath); + + expect(existsSync(expectedWorktreePath)).toBeTrue(); + expect(existsSync(join(expectedWorktreePath, ".wt", "meta.json"))).toBeTrue(); + expect(result.stdout).toContain(`WT_PATH: ${resolvedWorktreePath}`); + expect(result.stdout).toContain(`cd ${resolvedWorktreePath}`); + }); + + test("resolves changed linked shared worktreeDir from the linked worktree", async () => { + const repo = await createTestRepo(); + const firstWorktreeId = "relative-shared-source"; + const secondWorktreeId = "relative-shared-target"; + const firstBranchName = "feature-relative-shared-source"; + const secondBranchName = "feature-relative-shared-target"; + const mainWorktreeRoot = join(repo.repoRoot, "worktrees"); + const firstWorktreePath = join( + mainWorktreeRoot, + `${repo.repoName}-${firstWorktreeId}` + ); + const expectedWorktreeRoot = join(firstWorktreePath, "branch-worktrees"); + const expectedWorktreePath = join( + expectedWorktreeRoot, + `${repo.repoName}-${secondWorktreeId}` + ); + + updateSettings(repo.repoRoot, settings => { + settings.worktreeDir = "./worktrees"; + }); + await $`git -C ${repo.repoRoot} add .wt/settings.json`.quiet(); + await $`git -C ${repo.repoRoot} commit -m track-settings`.quiet(); + runCli( + ["new", firstBranchName, "--id", firstWorktreeId, "--no-cd"], + repo.repoRoot + ); + writeFileSync( + join(firstWorktreePath, ".wt", "settings.json"), + JSON.stringify({ worktreeDir: "./branch-worktrees" }, null, 2) + ); + + const result = runCliCapture( + ["new", secondBranchName, "--id", secondWorktreeId, "--no-cd"], + firstWorktreePath + ); + + assertProcessSuccess(result.status, result.stderr, result.stdout); + + const resolvedWorktreePath = realpathSync(expectedWorktreePath); + + expect(existsSync(expectedWorktreePath)).toBeTrue(); + expect(existsSync(join(expectedWorktreePath, ".wt", "meta.json"))).toBeTrue(); + expect(result.stdout).toContain(`WT_PATH: ${resolvedWorktreePath}`); + expect(result.stdout).toContain(`cd ${resolvedWorktreePath}`); + }); }); diff --git a/src/commands/current.ts b/src/commands/current.ts new file mode 100644 index 0000000..627569d --- /dev/null +++ b/src/commands/current.ts @@ -0,0 +1,39 @@ +import chalk from "chalk"; +import { getCurrentWorktree } from "../app/use-cases/get-current-worktree.js"; +import { runCommand } from "../cli/command-runtime.js"; +import { + buildIssueSummary, + buildPullRequestSummary, + buildWorktreeBranchSummary, + buildWorktreeIdLabel, +} from "./worktree-display.js"; + +export async function currentCommand(): Promise { + await runCommand(async () => { + const result = await getCurrentWorktree(); + const { worktree } = result; + const pullRequestSummary = buildPullRequestSummary(worktree); + const issueSummary = buildIssueSummary(worktree); + const timestamp = new Date(worktree.createdAt).toLocaleString(); + + console.log(chalk.bold(`\nCurrent worktree (${result.repoName}):`)); + console.log(chalk.dim("─".repeat(80))); + console.log( + chalk.cyan( + `ID: ${buildWorktreeIdLabel({ ...worktree, isCurrent: true })}` + ) + ); + console.log( + chalk.white(`Branch: ${buildWorktreeBranchSummary(worktree)}`) + ); + if (issueSummary) { + console.log(`Issue: ${issueSummary}`); + } + if (pullRequestSummary) { + console.log(`PR: ${pullRequestSummary}`); + } + console.log(chalk.dim(`Path: ${worktree.path}`)); + console.log(chalk.dim(`Created: ${timestamp}`)); + console.log(chalk.dim("─".repeat(80))); + }); +} diff --git a/src/commands/list.ts b/src/commands/list.ts index 6640d4b..a161314 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -14,6 +14,8 @@ import type { import { runCommand } from "../cli/command-runtime.js"; import { buildBaseDescription, + buildIssueSummary, + buildPullRequestSummary, buildWorktreeBranchSummary, buildWorktreeIdLabel, } from "./worktree-display.js"; @@ -141,27 +143,3 @@ function buildRemoveCompletionDescription( return `${getWorktreeBranchLabel(worktree)} | ${metadata.join(" | ")}`; } - -function buildPullRequestSummary( - worktree: Pick -): string | undefined { - if (!worktree.prUrl) { - return undefined; - } - - return worktree.prNumber - ? `#${worktree.prNumber} ${worktree.prUrl}` - : worktree.prUrl; -} - -function buildIssueSummary( - worktree: Pick -): string | undefined { - if (!worktree.issueUrl) { - return undefined; - } - - return worktree.issueKey - ? `${worktree.issueKey} ${worktree.issueUrl}` - : worktree.issueUrl; -} diff --git a/src/commands/worktree-display.ts b/src/commands/worktree-display.ts index e9b8ff0..dbfce10 100644 --- a/src/commands/worktree-display.ts +++ b/src/commands/worktree-display.ts @@ -99,6 +99,30 @@ export function buildWorktreeBranchSummary( return branchParts.join(" "); } +export function buildPullRequestSummary( + worktree: Pick +): string | undefined { + if (!worktree.prUrl) { + return undefined; + } + + return worktree.prNumber + ? `#${worktree.prNumber} ${worktree.prUrl}` + : worktree.prUrl; +} + +export function buildIssueSummary( + worktree: Pick +): string | undefined { + if (!worktree.issueUrl) { + return undefined; + } + + return worktree.issueKey + ? `${worktree.issueKey} ${worktree.issueUrl}` + : worktree.issueUrl; +} + export function pickDisplayState( worktree: Pick, statesById: Map diff --git a/src/index.ts b/src/index.ts index 6c489fa..822f020 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { Command, Option } from "commander"; import { initCommand } from "./commands/init.js"; import { newCommand } from "./commands/new.js"; import { listCommand } from "./commands/list.js"; +import { currentCommand } from "./commands/current.js"; import { removeCommand } from "./commands/remove.js"; import { renameCommand } from "./commands/rename.js"; import { cleanCommand } from "./commands/clean.js"; @@ -44,6 +45,11 @@ program .addOption(new Option("--exclude-main-worktree").hideHelp()) .action(listCommand); +program + .command("current") + .description("Show the current worktree") + .action(currentCommand); + program .command("remove ") .alias("rm") diff --git a/src/infra/github/cli.ts b/src/infra/github/cli.ts index 4eccf07..fcffdcb 100644 --- a/src/infra/github/cli.ts +++ b/src/infra/github/cli.ts @@ -27,6 +27,11 @@ interface PullRequestListEntry { url: string; } +interface PullRequestLinkEntry { + number: number; + url: string; +} + function runGithubCommand( args: string[], cwd: string @@ -58,6 +63,33 @@ function getGithubErrorMessage(result: GithubCommandResult): string { return result.stderr.trim() || result.stdout.trim() || "unknown gh error"; } +function parsePullRequestLinkList(stdout: string): PullRequestLink[] { + let entries: Partial[]; + + try { + entries = JSON.parse(stdout) as Partial[]; + } catch { + return []; + } + + if (!Array.isArray(entries)) { + return []; + } + + return entries.flatMap((entry) => { + if (typeof entry.number !== "number" || !entry.url) { + return []; + } + + return [ + { + number: String(entry.number), + url: entry.url, + }, + ]; + }); +} + export async function ensureGithubCliReady(cwd: string): Promise { const versionResult = runGithubCommand(["--version"], cwd); @@ -194,6 +226,33 @@ export async function listPullRequestLinks( ); } +export async function findPullRequestLinkForBranch( + repoRoot: string, + branchName: string +): Promise { + const result = runGithubCommand( + [ + "pr", + "list", + "--state", + "open", + "--head", + branchName, + "--json", + "number,url", + ], + repoRoot + ); + + if (result.status !== 0) { + return undefined; + } + + const pullRequests = parsePullRequestLinkList(result.stdout); + + return pullRequests.length === 1 ? pullRequests[0] : undefined; +} + export async function checkoutPullRequest( worktreePath: string, pullRequestNumber: string diff --git a/src/infra/shell/installer.test.ts b/src/infra/shell/installer.test.ts index 2adb92f..4e061b1 100644 --- a/src/infra/shell/installer.test.ts +++ b/src/infra/shell/installer.test.ts @@ -38,6 +38,7 @@ describe("shell installer", () => { }) ); expect(wrapper).toContain('clean[Bulk-remove worktrees interactively]'); + expect(wrapper).toContain('current[Show the current worktree]'); expect(wrapper).toContain('rename[Rename the current worktree ID]'); } finally { rmSync(shellDir, { recursive: true, force: true }); diff --git a/src/infra/shell/installer.ts b/src/infra/shell/installer.ts index 7a0f51d..e4513e4 100644 --- a/src/infra/shell/installer.ts +++ b/src/infra/shell/installer.ts @@ -227,6 +227,7 @@ _wt_completion() { 'switch[Alias for checkout]' \\ 'list[List all worktrees]' \\ 'ls[List all worktrees]' \\ + 'current[Show the current worktree]' \\ 'remove[Remove a worktree]' \\ 'rm[Remove a worktree]' \\ 'rename[Rename the current worktree ID]' \\ diff --git a/src/infra/storage/settings-store.ts b/src/infra/storage/settings-store.ts index e470258..c90e822 100644 --- a/src/infra/storage/settings-store.ts +++ b/src/infra/storage/settings-store.ts @@ -21,6 +21,10 @@ export async function getSettingsPath(repoRoot: string): Promise { return join(repoRoot, ".wt", "settings.json"); } +export async function getLocalSettingsPath(repoRoot: string): Promise { + return join(repoRoot, ".wt", "settings.local.json"); +} + export async function settingsExist(repoRoot: string): Promise { const settingsPath = await getSettingsPath(repoRoot); return existsSync(settingsPath); @@ -36,13 +40,27 @@ async function readSettingsInput( return JSON.parse(await Bun.file(settingsPath).text()) as WtSettingsInput; } -export async function loadSettings(repoRoot: string): Promise { +export interface SettingsInputs { + shared?: WtSettingsInput; + local?: WtSettingsInput; +} + +export async function loadSettingsInputs( + repoRoot: string +): Promise { const settingsPath = await getSettingsPath(repoRoot); - const localSettingsPath = join(repoRoot, ".wt", "settings.local.json"); - const sharedSettings = await readSettingsInput(settingsPath); - const localSettings = await readSettingsInput(localSettingsPath); + const localSettingsPath = await getLocalSettingsPath(repoRoot); + + return { + shared: await readSettingsInput(settingsPath), + local: await readSettingsInput(localSettingsPath), + }; +} + +export async function loadSettings(repoRoot: string): Promise { + const { shared, local } = await loadSettingsInputs(repoRoot); - return normalizeSettings(mergeSettingsInputs(sharedSettings, localSettings)); + return normalizeSettings(mergeSettingsInputs(shared, local)); } export async function ensureLocalSettingsIgnored(