From 3569105a88984cdf3228bc58835b03061cbb2bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Sun, 14 Jun 2026 06:13:48 +0200 Subject: [PATCH 01/10] refactor: consolidate worktree entry type --- src/cli/resolve-batch-targets.ts | 8 +------- src/git/get-worktree-info.ts | 8 +------- src/git/parse-worktree-list.ts | 8 +------- src/git/worktree-entry.ts | 6 ++++++ src/worktree/get-removal-display.test.ts | 8 +------- src/worktree/get-removal-display.ts | 8 +------- src/worktree/resolve-removal-target.ts | 8 +------- src/worktree/resolve-worktree-target.ts | 8 +------- 8 files changed, 13 insertions(+), 49 deletions(-) create mode 100644 src/git/worktree-entry.ts diff --git a/src/cli/resolve-batch-targets.ts b/src/cli/resolve-batch-targets.ts index 49871b8..dbcca4d 100644 --- a/src/cli/resolve-batch-targets.ts +++ b/src/cli/resolve-batch-targets.ts @@ -11,13 +11,7 @@ import { hasUncommittedChanges } from "../git/check-uncommitted-changes.js"; import { assertRemovalSafe } from "../worktree/assert-removal-safe.js"; import { getRemovalDisplayInfo, type RemovalDisplayInfo } from "../worktree/get-removal-display.js"; import { resolveRemovalTarget } from "../worktree/resolve-removal-target.js"; - -interface WorktreeEntry { - path: string; - head: string | undefined; - branch: string | undefined; - isDetached: boolean; -} +import type { WorktreeEntry } from "../git/worktree-entry.js"; export interface ResolvedTarget { input: string; diff --git a/src/git/get-worktree-info.ts b/src/git/get-worktree-info.ts index ecd6dfc..3464519 100644 --- a/src/git/get-worktree-info.ts +++ b/src/git/get-worktree-info.ts @@ -5,13 +5,7 @@ import { git, exitWithMessage } from "./git-helpers.js"; import { normalizeGitPath } from "./normalize-git-path.js"; import { parseWorktreeListPorcelain } from "./parse-worktree-list.js"; - -interface WorktreeEntry { - path: string; - head: string | undefined; - branch: string | undefined; - isDetached: boolean; -} +import type { WorktreeEntry } from "./worktree-entry.js"; interface WorktreeInfo { mainPath: string; diff --git a/src/git/parse-worktree-list.ts b/src/git/parse-worktree-list.ts index 53c0198..352da0a 100644 --- a/src/git/parse-worktree-list.ts +++ b/src/git/parse-worktree-list.ts @@ -1,11 +1,5 @@ import { normalizeBranchName } from "./git-helpers.js"; - -interface WorktreeEntry { - path: string; - head: string | undefined; - branch: string | undefined; - isDetached: boolean; -} +import type { WorktreeEntry } from "./worktree-entry.js"; interface ParsedWorktreeList { mainPath: string; diff --git a/src/git/worktree-entry.ts b/src/git/worktree-entry.ts new file mode 100644 index 0000000..75b28d5 --- /dev/null +++ b/src/git/worktree-entry.ts @@ -0,0 +1,6 @@ +export interface WorktreeEntry { + path: string; + head: string | undefined; + branch: string | undefined; + isDetached: boolean; +} diff --git a/src/worktree/get-removal-display.test.ts b/src/worktree/get-removal-display.test.ts index 3e9594f..88cf571 100644 --- a/src/worktree/get-removal-display.test.ts +++ b/src/worktree/get-removal-display.test.ts @@ -1,12 +1,6 @@ import { describe, it, expect } from "vitest"; import { getRemovalDisplayInfo } from "./get-removal-display.js"; - -interface WorktreeEntry { - path: string; - head: string | undefined; - branch: string | undefined; - isDetached: boolean; -} +import type { WorktreeEntry } from "../git/worktree-entry.js"; const worktreeWithBranch: WorktreeEntry = { path: "/Users/acme/repo-feature", diff --git a/src/worktree/get-removal-display.ts b/src/worktree/get-removal-display.ts index 80364f5..c8c34ea 100644 --- a/src/worktree/get-removal-display.ts +++ b/src/worktree/get-removal-display.ts @@ -1,11 +1,5 @@ import path from "node:path"; - -interface WorktreeEntry { - path: string; - head: string | undefined; - branch: string | undefined; - isDetached: boolean; -} +import type { WorktreeEntry } from "../git/worktree-entry.js"; interface RemovalDisplayInput { cwd: string; diff --git a/src/worktree/resolve-removal-target.ts b/src/worktree/resolve-removal-target.ts index 592f3a3..caa9977 100644 --- a/src/worktree/resolve-removal-target.ts +++ b/src/worktree/resolve-removal-target.ts @@ -2,13 +2,7 @@ import path from "node:path"; import { exitWithMessage } from "../git/git-helpers.js"; import { directoryExists } from "../fs/check-directory-exists.js"; import { resolveWorktreeTarget } from "./resolve-worktree-target.js"; - -interface WorktreeEntry { - path: string; - head: string | undefined; - branch: string | undefined; - isDetached: boolean; -} +import type { WorktreeEntry } from "../git/worktree-entry.js"; interface ResolveRemovalTargetInput { input: string; diff --git a/src/worktree/resolve-worktree-target.ts b/src/worktree/resolve-worktree-target.ts index 757eba5..216e208 100644 --- a/src/worktree/resolve-worktree-target.ts +++ b/src/worktree/resolve-worktree-target.ts @@ -3,13 +3,7 @@ import path from "node:path"; import { normalizeBranchName } from "../git/git-helpers.js"; import { normalizeGitPath } from "../git/normalize-git-path.js"; import { normalizePathKey } from "../fs/normalize-path-key.js"; - -interface WorktreeEntry { - path: string; - head: string | undefined; - branch: string | undefined; - isDetached: boolean; -} +import type { WorktreeEntry } from "../git/worktree-entry.js"; interface ResolveWorktreeTargetInput { input: string; From 812434eaf08f25b03fe2ce642edba2b53e8bb202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Sun, 14 Jun 2026 06:20:46 +0200 Subject: [PATCH 02/10] refactor: remove inclusive path helper --- src/cli/handle-cwd-switch.ts | 16 ++++--- src/cli/select-worktree.ts | 28 +++++++----- src/worktree/assert-removal-safe.ts | 2 +- ...est.ts => is-path-strictly-within.test.ts} | 44 +++++-------------- ...r-within.ts => is-path-strictly-within.ts} | 15 +------ 5 files changed, 39 insertions(+), 66 deletions(-) rename src/worktree/{is-path-equal-or-within.test.ts => is-path-strictly-within.test.ts} (70%) rename src/worktree/{is-path-equal-or-within.ts => is-path-strictly-within.ts} (61%) diff --git a/src/cli/handle-cwd-switch.ts b/src/cli/handle-cwd-switch.ts index f9ea1f9..196e10f 100644 --- a/src/cli/handle-cwd-switch.ts +++ b/src/cli/handle-cwd-switch.ts @@ -6,7 +6,7 @@ import type { OutputWriter } from "./output-writer.js"; import { normalizePathKey } from "../fs/normalize-path-key.js"; import { exitWithMessage } from "../git/git-helpers.js"; -import { isPathEqualOrWithin } from "../worktree/is-path-equal-or-within.js"; +import { isPathStrictlyWithin } from "../worktree/is-path-strictly-within.js"; interface TargetEntry { path: string; @@ -30,12 +30,14 @@ interface CwdSwitchInput { export function prepareCwdSwitch(input: CwdSwitchInput): (() => void) | undefined { const { targets, invocationCwd, mainPath, dryRun, output } = input; - const matchingTarget = targets.find((t) => - isPathEqualOrWithin({ - basePath: t.path, - candidatePath: invocationCwd, - platform: process.platform, - }), + const matchingTarget = targets.find( + (t) => + normalizePathKey(t.path) === normalizePathKey(invocationCwd) || + isPathStrictlyWithin({ + basePath: t.path, + candidatePath: invocationCwd, + platform: process.platform, + }), ); if (!matchingTarget || normalizePathKey(invocationCwd) === normalizePathKey(mainPath)) { diff --git a/src/cli/select-worktree.ts b/src/cli/select-worktree.ts index c36ccb3..6a0bc6d 100644 --- a/src/cli/select-worktree.ts +++ b/src/cli/select-worktree.ts @@ -7,7 +7,8 @@ import { checkbox } from "@inquirer/prompts"; import chalk from "chalk"; import { getWorktreeInfo } from "../git/get-worktree-info.js"; import type { OutputWriter } from "./output-writer.js"; -import { isPathEqualOrWithin } from "../worktree/is-path-equal-or-within.js"; +import { normalizePathKey } from "../fs/normalize-path-key.js"; +import { isPathStrictlyWithin } from "../worktree/is-path-strictly-within.js"; const shouldUseColor = process.stderr.isTTY && !process.env.NO_COLOR; chalk.level = shouldUseColor ? 3 : 0; @@ -26,22 +27,25 @@ export async function selectWorktrees(output: OutputWriter): Promise { } const currentLinkedWorktreePath = sortedWorktrees - .filter((worktree) => - isPathEqualOrWithin({ - basePath: worktree.path, - candidatePath: cwd, - platform: process.platform, - }), + .filter( + (worktree) => + normalizePathKey(worktree.path) === normalizePathKey(cwd) || + isPathStrictlyWithin({ + basePath: worktree.path, + candidatePath: cwd, + platform: process.platform, + }), ) .toSorted((a, b) => b.path.length - a.path.length)[0]?.path; const isCurrentMain = currentLinkedWorktreePath === undefined && - isPathEqualOrWithin({ - basePath: mainPath, - candidatePath: cwd, - platform: process.platform, - }); + (normalizePathKey(mainPath) === normalizePathKey(cwd) || + isPathStrictlyWithin({ + basePath: mainPath, + candidatePath: cwd, + platform: process.platform, + })); const choices = [ { diff --git a/src/worktree/assert-removal-safe.ts b/src/worktree/assert-removal-safe.ts index f9fb80c..48d7797 100644 --- a/src/worktree/assert-removal-safe.ts +++ b/src/worktree/assert-removal-safe.ts @@ -1,6 +1,6 @@ import { exitWithMessage } from "../git/git-helpers.js"; import { normalizePathKey } from "../fs/normalize-path-key.js"; -import { isPathStrictlyWithin } from "./is-path-equal-or-within.js"; +import { isPathStrictlyWithin } from "./is-path-strictly-within.js"; interface RemovalSafetyInput { targetPath: string; diff --git a/src/worktree/is-path-equal-or-within.test.ts b/src/worktree/is-path-strictly-within.test.ts similarity index 70% rename from src/worktree/is-path-equal-or-within.test.ts rename to src/worktree/is-path-strictly-within.test.ts index 73e5a96..416e8c2 100644 --- a/src/worktree/is-path-equal-or-within.test.ts +++ b/src/worktree/is-path-strictly-within.test.ts @@ -1,20 +1,20 @@ import { describe, expect, it } from "vitest"; -import { isPathEqualOrWithin, isPathStrictlyWithin } from "./is-path-equal-or-within.js"; +import { isPathStrictlyWithin } from "./is-path-strictly-within.js"; -describe("isPathEqualOrWithin", () => { - it("returns true for the same POSIX path", () => { +describe("isPathStrictlyWithin", () => { + it("returns false for equal paths", () => { expect( - isPathEqualOrWithin({ + isPathStrictlyWithin({ basePath: "/Users/acme/repo-feature", candidatePath: "/Users/acme/repo-feature", platform: "linux", }), - ).toBe(true); + ).not.toBe(true); }); it("returns true when the candidate path is inside the base path", () => { expect( - isPathEqualOrWithin({ + isPathStrictlyWithin({ basePath: "/Users/acme/repo-feature", candidatePath: "/Users/acme/repo-feature/src", platform: "linux", @@ -24,7 +24,7 @@ describe("isPathEqualOrWithin", () => { it("returns false when the candidate path is outside the base path", () => { expect( - isPathEqualOrWithin({ + isPathStrictlyWithin({ basePath: "/Users/acme/repo-feature", candidatePath: "/Users/acme/repo-main", platform: "linux", @@ -34,7 +34,7 @@ describe("isPathEqualOrWithin", () => { it("allows names prefixed with '..' that do not traverse upward", () => { expect( - isPathEqualOrWithin({ + isPathStrictlyWithin({ basePath: "/Users/acme/repo-feature", candidatePath: "/Users/acme/repo-feature/..not-parent/tmp", platform: "linux", @@ -44,7 +44,7 @@ describe("isPathEqualOrWithin", () => { it("treats Windows paths case-insensitively", () => { expect( - isPathEqualOrWithin({ + isPathStrictlyWithin({ basePath: String.raw`C:\Users\Acme\Repo-Feature`, candidatePath: String.raw`c:\users\acme\repo-feature\src`, platform: "win32", @@ -54,7 +54,7 @@ describe("isPathEqualOrWithin", () => { it("returns false for Windows paths on another drive", () => { expect( - isPathEqualOrWithin({ + isPathStrictlyWithin({ basePath: String.raw`C:\Users\Acme\Repo-Feature`, candidatePath: String.raw`D:\Users\Acme\Repo-Feature\src`, platform: "win32", @@ -64,7 +64,7 @@ describe("isPathEqualOrWithin", () => { it("returns false when one of the paths is relative", () => { expect( - isPathEqualOrWithin({ + isPathStrictlyWithin({ basePath: "repo-feature", candidatePath: "/Users/acme/repo-feature/src", platform: "linux", @@ -72,25 +72,3 @@ describe("isPathEqualOrWithin", () => { ).not.toBe(true); }); }); - -describe("isPathStrictlyWithin", () => { - it("returns false for equal paths", () => { - expect( - isPathStrictlyWithin({ - basePath: "/Users/acme/repo-feature", - candidatePath: "/Users/acme/repo-feature", - platform: "linux", - }), - ).not.toBe(true); - }); - - it("returns true when the candidate path is inside the base path", () => { - expect( - isPathStrictlyWithin({ - basePath: "/Users/acme/repo-feature", - candidatePath: "/Users/acme/repo-feature/src", - platform: "linux", - }), - ).toBe(true); - }); -}); diff --git a/src/worktree/is-path-equal-or-within.ts b/src/worktree/is-path-strictly-within.ts similarity index 61% rename from src/worktree/is-path-equal-or-within.ts rename to src/worktree/is-path-strictly-within.ts index 57c1675..f39b00a 100644 --- a/src/worktree/is-path-equal-or-within.ts +++ b/src/worktree/is-path-strictly-within.ts @@ -6,11 +6,8 @@ interface PathContainmentInput { platform: NodeJS.Platform; } -function isPathWithin(input: PathContainmentInput, includeEqual: boolean): boolean { +export function isPathStrictlyWithin(input: PathContainmentInput): boolean { const pathApi = input.platform === "win32" ? path.win32 : path.posix; - // This helper is designed for already-resolved absolute paths. - // Forcing absolute inputs avoids host/target path-API mismatches such as - // `path.win32.resolve` behavior on POSIX hosts for relative values. if (!pathApi.isAbsolute(input.basePath)) { return false; } @@ -24,7 +21,7 @@ function isPathWithin(input: PathContainmentInput, includeEqual: boolean): boole ); if (relative === "") { - return includeEqual; + return false; } if (pathApi.isAbsolute(relative)) { return false; @@ -34,11 +31,3 @@ function isPathWithin(input: PathContainmentInput, includeEqual: boolean): boole } return true; } - -export function isPathEqualOrWithin(input: PathContainmentInput): boolean { - return isPathWithin(input, true); -} - -export function isPathStrictlyWithin(input: PathContainmentInput): boolean { - return isPathWithin(input, false); -} From 25021460766527a3a55952d58050db2ce19bb4d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Sun, 14 Jun 2026 06:23:36 +0200 Subject: [PATCH 03/10] refactor: centralize chalk color setup --- src/cli/select-worktree.ts | 3 --- src/git/git-helpers.ts | 3 --- 2 files changed, 6 deletions(-) diff --git a/src/cli/select-worktree.ts b/src/cli/select-worktree.ts index 6a0bc6d..865579f 100644 --- a/src/cli/select-worktree.ts +++ b/src/cli/select-worktree.ts @@ -10,9 +10,6 @@ import type { OutputWriter } from "./output-writer.js"; import { normalizePathKey } from "../fs/normalize-path-key.js"; import { isPathStrictlyWithin } from "../worktree/is-path-strictly-within.js"; -const shouldUseColor = process.stderr.isTTY && !process.env.NO_COLOR; -chalk.level = shouldUseColor ? 3 : 0; - export async function selectWorktrees(output: OutputWriter): Promise { const { mainPath, worktrees } = getWorktreeInfo(); const cwd = process.cwd(); diff --git a/src/git/git-helpers.ts b/src/git/git-helpers.ts index 181e456..d29b1e4 100644 --- a/src/git/git-helpers.ts +++ b/src/git/git-helpers.ts @@ -2,9 +2,6 @@ import { spawnSync } from "node:child_process"; import * as readline from "node:readline/promises"; import chalk from "chalk"; -const shouldUseColor = process.stderr.isTTY && !process.env.NO_COLOR; -chalk.level = shouldUseColor ? 3 : 0; - function getGitExecutable(): string { const configuredPath = process.env.WORKTREE_REMOVE_GIT_PATH?.trim(); return configuredPath && configuredPath.length > 0 ? configuredPath : "git"; From f092f3a549f456a96b953f0e37fef2931d739011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Sun, 14 Jun 2026 06:28:35 +0200 Subject: [PATCH 04/10] refactor: rely on commander option inference --- src/cli.ts | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index e72dd13..9c11354 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,16 +1,4 @@ #!/usr/bin/env -S node --experimental-strip-types -/** - * worktree-remove.ts - * - * Remove one or more Git worktrees and/or their directories for given targets. - * Handles registered worktrees, detached HEAD worktrees, and orphaned directories. - * - * This script: - * 1. Resolves the targets (branch/path/directory name) - * 2. Checks if they're registered as Git worktrees - * 3. Unregisters them if needed - * 4. Offers to delete the directories if they exist - */ import { Command } from "@commander-js/extra-typings"; import chalk from "chalk"; @@ -29,15 +17,6 @@ process.on("SIGINT", () => { process.exit(130); }); -interface CliOptions { - interactive?: boolean; - yes?: boolean; - force?: boolean; - dryRun?: boolean; - verbose?: boolean; - quiet?: boolean; -} - const program = new Command() .name(packageJson.name) .description(packageJson.description) @@ -55,7 +34,7 @@ const program = new Command() .option("--quiet", "suppress non-error output") .showHelpAfterError("(add --help for additional information)") .showSuggestionAfterError() - .action(async (targets: string[], options: CliOptions) => { + .action(async (targets, options) => { try { ensureGitAvailable(); From 524a325a721c1b2ef34d195695d1430da0ca612f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Sun, 14 Jun 2026 06:30:42 +0200 Subject: [PATCH 05/10] refactor: simplify output writer state --- src/cli/output-writer.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/cli/output-writer.ts b/src/cli/output-writer.ts index 23eaad5..1203e03 100644 --- a/src/cli/output-writer.ts +++ b/src/cli/output-writer.ts @@ -8,9 +8,6 @@ export interface OutputWriter { info: (message: string) => void; warn: (message: string) => void; error: (message: string) => void; - isDryRun: boolean; - isVerbose: boolean; - isQuiet: boolean; } export function prefixOutput(output: OutputWriter, prefix: string): OutputWriter { @@ -21,9 +18,6 @@ export function prefixOutput(output: OutputWriter, prefix: string): OutputWriter info: wrap(output.info), warn: wrap(output.warn), error: wrap(output.error), - isDryRun: output.isDryRun, - isVerbose: output.isVerbose, - isQuiet: output.isQuiet, }; } @@ -56,8 +50,5 @@ export function createOutputWriter(options: OutputWriterOptions): OutputWriter { info, warn, error: writeError, - isDryRun: options.dryRun, - isVerbose: options.verbose, - isQuiet: options.quiet, }; } From 0a0ccdc0fe7ed4c4324d069505bec0db6646c104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Sun, 14 Jun 2026 06:36:09 +0200 Subject: [PATCH 06/10] ci: deduplicate workflow configuration --- .github/workflows/commit-msg.yml | 12 +++++++----- .github/workflows/pr-review.yml | 21 ++++++++------------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/.github/workflows/commit-msg.yml b/.github/workflows/commit-msg.yml index c4147f8..ac9126b 100644 --- a/.github/workflows/commit-msg.yml +++ b/.github/workflows/commit-msg.yml @@ -37,11 +37,13 @@ jobs: set -euo pipefail if [[ "${{ github.event_name }}" == "pull_request" ]]; then - npm exec --yes --package=conventional-commit-msg@${COMMITLINT_PACKAGE_VERSION} -- \ - conventional-commit-msg \ - --from "${{ github.event.pull_request.base.sha }}" \ + commitlint_args=( + --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}" + ) else - npm exec --yes --package=conventional-commit-msg@${COMMITLINT_PACKAGE_VERSION} -- \ - conventional-commit-msg --last + commitlint_args=(--last) fi + + npm exec --yes --package=conventional-commit-msg@${COMMITLINT_PACKAGE_VERSION} -- \ + conventional-commit-msg "${commitlint_args[@]}" diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index 3d948b0..096dc57 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -14,29 +14,24 @@ on: jobs: approach: if: ${{ github.event_name == 'workflow_dispatch' || github.event.action == 'opened' }} - permissions: + permissions: &review-permissions contents: read pull-requests: write actions: read - uses: Jercik/axgithub/.github/workflows/pr-review.yml@v1 + uses: &review-workflow Jercik/axgithub/.github/workflows/pr-review.yml@v1 with: label: approach recipes: '["pr-review-approach-1","pr-review-approach-2","pr-review-approach-3","pr-review-approach-4"]' - pr_number: ${{ github.event.pull_request.number || inputs.pr_number }} - secrets: + pr_number: &review-pr-number ${{ github.event.pull_request.number || inputs.pr_number }} + secrets: &review-secrets NPM_TOKEN: ${{ secrets.NPM_TOKEN }} AXRECIPE_API_KEY: ${{ secrets.AXRECIPE_API_KEY }} code: - permissions: - contents: read - pull-requests: write - actions: read - uses: Jercik/axgithub/.github/workflows/pr-review.yml@v1 + permissions: *review-permissions + uses: *review-workflow with: label: code recipes: '["pr-review-code-1","pr-review-code-2"]' - pr_number: ${{ github.event.pull_request.number || inputs.pr_number }} - secrets: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - AXRECIPE_API_KEY: ${{ secrets.AXRECIPE_API_KEY }} + pr_number: *review-pr-number + secrets: *review-secrets From ba5814f374122d6cb48b9ffe579ddfc555db9618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Sun, 14 Jun 2026 06:39:26 +0200 Subject: [PATCH 07/10] chore: ignore .clawpatch artifacts --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 98a42af..8ad0240 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,4 @@ dist # Local artifacts .codex/ +.clawpatch From d30e8d3954305727fd881bd74c8b7a777b0b583d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Sun, 14 Jun 2026 06:44:46 +0200 Subject: [PATCH 08/10] refactor: reintroduce composed isPathEqualOrWithin helper --- src/cli/handle-cwd-switch.ts | 16 +++--- src/cli/select-worktree.ts | 28 +++++----- src/worktree/is-path-equal-or-within.test.ts | 54 ++++++++++++++++++++ src/worktree/is-path-equal-or-within.ts | 15 ++++++ 4 files changed, 88 insertions(+), 25 deletions(-) create mode 100644 src/worktree/is-path-equal-or-within.test.ts create mode 100644 src/worktree/is-path-equal-or-within.ts diff --git a/src/cli/handle-cwd-switch.ts b/src/cli/handle-cwd-switch.ts index 196e10f..f9ea1f9 100644 --- a/src/cli/handle-cwd-switch.ts +++ b/src/cli/handle-cwd-switch.ts @@ -6,7 +6,7 @@ import type { OutputWriter } from "./output-writer.js"; import { normalizePathKey } from "../fs/normalize-path-key.js"; import { exitWithMessage } from "../git/git-helpers.js"; -import { isPathStrictlyWithin } from "../worktree/is-path-strictly-within.js"; +import { isPathEqualOrWithin } from "../worktree/is-path-equal-or-within.js"; interface TargetEntry { path: string; @@ -30,14 +30,12 @@ interface CwdSwitchInput { export function prepareCwdSwitch(input: CwdSwitchInput): (() => void) | undefined { const { targets, invocationCwd, mainPath, dryRun, output } = input; - const matchingTarget = targets.find( - (t) => - normalizePathKey(t.path) === normalizePathKey(invocationCwd) || - isPathStrictlyWithin({ - basePath: t.path, - candidatePath: invocationCwd, - platform: process.platform, - }), + const matchingTarget = targets.find((t) => + isPathEqualOrWithin({ + basePath: t.path, + candidatePath: invocationCwd, + platform: process.platform, + }), ); if (!matchingTarget || normalizePathKey(invocationCwd) === normalizePathKey(mainPath)) { diff --git a/src/cli/select-worktree.ts b/src/cli/select-worktree.ts index 865579f..d27fec0 100644 --- a/src/cli/select-worktree.ts +++ b/src/cli/select-worktree.ts @@ -7,8 +7,7 @@ import { checkbox } from "@inquirer/prompts"; import chalk from "chalk"; import { getWorktreeInfo } from "../git/get-worktree-info.js"; import type { OutputWriter } from "./output-writer.js"; -import { normalizePathKey } from "../fs/normalize-path-key.js"; -import { isPathStrictlyWithin } from "../worktree/is-path-strictly-within.js"; +import { isPathEqualOrWithin } from "../worktree/is-path-equal-or-within.js"; export async function selectWorktrees(output: OutputWriter): Promise { const { mainPath, worktrees } = getWorktreeInfo(); @@ -24,25 +23,22 @@ export async function selectWorktrees(output: OutputWriter): Promise { } const currentLinkedWorktreePath = sortedWorktrees - .filter( - (worktree) => - normalizePathKey(worktree.path) === normalizePathKey(cwd) || - isPathStrictlyWithin({ - basePath: worktree.path, - candidatePath: cwd, - platform: process.platform, - }), + .filter((worktree) => + isPathEqualOrWithin({ + basePath: worktree.path, + candidatePath: cwd, + platform: process.platform, + }), ) .toSorted((a, b) => b.path.length - a.path.length)[0]?.path; const isCurrentMain = currentLinkedWorktreePath === undefined && - (normalizePathKey(mainPath) === normalizePathKey(cwd) || - isPathStrictlyWithin({ - basePath: mainPath, - candidatePath: cwd, - platform: process.platform, - })); + isPathEqualOrWithin({ + basePath: mainPath, + candidatePath: cwd, + platform: process.platform, + }); const choices = [ { diff --git a/src/worktree/is-path-equal-or-within.test.ts b/src/worktree/is-path-equal-or-within.test.ts new file mode 100644 index 0000000..6fb93f5 --- /dev/null +++ b/src/worktree/is-path-equal-or-within.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { isPathEqualOrWithin } from "./is-path-equal-or-within.js"; + +describe("isPathEqualOrWithin", () => { + it("returns true for the same POSIX path", () => { + expect( + isPathEqualOrWithin({ + basePath: "/Users/acme/repo-feature", + candidatePath: "/Users/acme/repo-feature", + platform: "linux", + }), + ).toBe(true); + }); + + it("returns true when the candidate path is inside the base path", () => { + expect( + isPathEqualOrWithin({ + basePath: "/Users/acme/repo-feature", + candidatePath: "/Users/acme/repo-feature/src", + platform: "linux", + }), + ).toBe(true); + }); + + it("returns false when the candidate path is outside the base path", () => { + expect( + isPathEqualOrWithin({ + basePath: "/Users/acme/repo-feature", + candidatePath: "/Users/acme/repo-main", + platform: "linux", + }), + ).not.toBe(true); + }); + + it("treats equal Windows paths case-insensitively", () => { + expect( + isPathEqualOrWithin({ + basePath: String.raw`C:\Users\Acme\Repo-Feature`, + candidatePath: String.raw`c:\users\acme\repo-feature`, + platform: "win32", + }), + ).toBe(true); + }); + + it("returns false for Windows paths on another drive", () => { + expect( + isPathEqualOrWithin({ + basePath: String.raw`C:\Users\Acme\Repo-Feature`, + candidatePath: String.raw`D:\Users\Acme\Repo-Feature\src`, + platform: "win32", + }), + ).not.toBe(true); + }); +}); diff --git a/src/worktree/is-path-equal-or-within.ts b/src/worktree/is-path-equal-or-within.ts new file mode 100644 index 0000000..a5a8034 --- /dev/null +++ b/src/worktree/is-path-equal-or-within.ts @@ -0,0 +1,15 @@ +import { normalizePathKey } from "../fs/normalize-path-key.js"; +import { isPathStrictlyWithin } from "./is-path-strictly-within.js"; + +interface PathContainmentInput { + basePath: string; + candidatePath: string; + platform: NodeJS.Platform; +} + +export function isPathEqualOrWithin(input: PathContainmentInput): boolean { + return ( + normalizePathKey(input.basePath, input.platform) === + normalizePathKey(input.candidatePath, input.platform) || isPathStrictlyWithin(input) + ); +} From 91febd03901fab74ae47fe77b30ebb32c70288d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Sun, 14 Jun 2026 06:50:13 +0200 Subject: [PATCH 09/10] docs: restore rationale for absolute-path guard --- src/worktree/is-path-strictly-within.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/worktree/is-path-strictly-within.ts b/src/worktree/is-path-strictly-within.ts index f39b00a..c84c42a 100644 --- a/src/worktree/is-path-strictly-within.ts +++ b/src/worktree/is-path-strictly-within.ts @@ -8,6 +8,7 @@ interface PathContainmentInput { export function isPathStrictlyWithin(input: PathContainmentInput): boolean { const pathApi = input.platform === "win32" ? path.win32 : path.posix; + // Require absolute inputs: path.win32.resolve mishandles relative paths on POSIX hosts. if (!pathApi.isAbsolute(input.basePath)) { return false; } From 54368b3499115b68fe59eaf5d403d7d462193d7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerci=C5=84ski?= Date: Sun, 14 Jun 2026 06:56:34 +0200 Subject: [PATCH 10/10] fix: enforce absolute-path contract in isPathEqualOrWithin --- src/worktree/is-path-equal-or-within.test.ts | 10 ++++++++++ src/worktree/is-path-equal-or-within.ts | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/src/worktree/is-path-equal-or-within.test.ts b/src/worktree/is-path-equal-or-within.test.ts index 6fb93f5..71b6aa5 100644 --- a/src/worktree/is-path-equal-or-within.test.ts +++ b/src/worktree/is-path-equal-or-within.test.ts @@ -51,4 +51,14 @@ describe("isPathEqualOrWithin", () => { }), ).not.toBe(true); }); + + it("returns false when one of the paths is relative", () => { + expect( + isPathEqualOrWithin({ + basePath: "repo-feature", + candidatePath: "repo-feature", + platform: "linux", + }), + ).not.toBe(true); + }); }); diff --git a/src/worktree/is-path-equal-or-within.ts b/src/worktree/is-path-equal-or-within.ts index a5a8034..d9ea6e8 100644 --- a/src/worktree/is-path-equal-or-within.ts +++ b/src/worktree/is-path-equal-or-within.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { normalizePathKey } from "../fs/normalize-path-key.js"; import { isPathStrictlyWithin } from "./is-path-strictly-within.js"; @@ -8,6 +9,11 @@ interface PathContainmentInput { } export function isPathEqualOrWithin(input: PathContainmentInput): boolean { + const pathApi = input.platform === "win32" ? path.win32 : path.posix; + // Require absolute inputs so the equality branch upholds the same contract as isPathStrictlyWithin. + if (!pathApi.isAbsolute(input.basePath) || !pathApi.isAbsolute(input.candidatePath)) { + return false; + } return ( normalizePathKey(input.basePath, input.platform) === normalizePathKey(input.candidatePath, input.platform) || isPathStrictlyWithin(input)