Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .github/workflows/commit-msg.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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[@]}"
21 changes: 8 additions & 13 deletions .github/workflows/pr-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
Jercik marked this conversation as resolved.
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,4 @@ dist

# Local artifacts
.codex/
.clawpatch
23 changes: 1 addition & 22 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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)
Expand All @@ -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();

Expand Down
9 changes: 0 additions & 9 deletions src/cli/output-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
};
}

Expand Down Expand Up @@ -56,8 +50,5 @@ export function createOutputWriter(options: OutputWriterOptions): OutputWriter {
info,
warn,
error: writeError,
isDryRun: options.dryRun,
isVerbose: options.verbose,
isQuiet: options.quiet,
};
}
8 changes: 1 addition & 7 deletions src/cli/resolve-batch-targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 0 additions & 3 deletions src/cli/select-worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ 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";

const shouldUseColor = process.stderr.isTTY && !process.env.NO_COLOR;
chalk.level = shouldUseColor ? 3 : 0;

export async function selectWorktrees(output: OutputWriter): Promise<string[]> {
const { mainPath, worktrees } = getWorktreeInfo();
const cwd = process.cwd();
Expand Down
8 changes: 1 addition & 7 deletions src/git/get-worktree-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 0 additions & 3 deletions src/git/git-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
8 changes: 1 addition & 7 deletions src/git/parse-worktree-list.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/git/worktree-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface WorktreeEntry {
Comment thread
Jercik marked this conversation as resolved.
Comment thread
Jercik marked this conversation as resolved.
path: string;
head: string | undefined;
branch: string | undefined;
isDetached: boolean;
}
2 changes: 1 addition & 1 deletion src/worktree/assert-removal-safe.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
8 changes: 1 addition & 7 deletions src/worktree/get-removal-display.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
8 changes: 1 addition & 7 deletions src/worktree/get-removal-display.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
40 changes: 4 additions & 36 deletions src/worktree/is-path-equal-or-within.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { isPathEqualOrWithin, isPathStrictlyWithin } from "./is-path-equal-or-within.js";
import { isPathEqualOrWithin } from "./is-path-equal-or-within.js";

describe("isPathEqualOrWithin", () => {
it("returns true for the same POSIX path", () => {
Expand Down Expand Up @@ -32,21 +32,11 @@ describe("isPathEqualOrWithin", () => {
).not.toBe(true);
});

it("allows names prefixed with '..' that do not traverse upward", () => {
expect(
isPathEqualOrWithin({
basePath: "/Users/acme/repo-feature",
candidatePath: "/Users/acme/repo-feature/..not-parent/tmp",
platform: "linux",
}),
).toBe(true);
});

it("treats Windows paths case-insensitively", () => {
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\src`,
candidatePath: String.raw`c:\users\acme\repo-feature`,
platform: "win32",
}),
).toBe(true);
Expand All @@ -66,31 +56,9 @@ describe("isPathEqualOrWithin", () => {
expect(
isPathEqualOrWithin({
basePath: "repo-feature",
candidatePath: "/Users/acme/repo-feature/src",
candidatePath: "repo-feature",
platform: "linux",
}),
).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);
});
});
39 changes: 8 additions & 31 deletions src/worktree/is-path-equal-or-within.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,21 @@
import path from "node:path";
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;
}

function isPathWithin(input: PathContainmentInput, includeEqual: boolean): boolean {
export function isPathEqualOrWithin(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;
}
if (!pathApi.isAbsolute(input.candidatePath)) {
// Require absolute inputs so the equality branch upholds the same contract as isPathStrictlyWithin.
if (!pathApi.isAbsolute(input.basePath) || !pathApi.isAbsolute(input.candidatePath)) {
return false;
}

const relative = pathApi.relative(
pathApi.resolve(input.basePath),
pathApi.resolve(input.candidatePath),
return (
Comment thread
Jercik marked this conversation as resolved.
normalizePathKey(input.basePath, input.platform) ===
Comment thread
Jercik marked this conversation as resolved.
Comment thread
Jercik marked this conversation as resolved.
normalizePathKey(input.candidatePath, input.platform) || isPathStrictlyWithin(input)
);

if (relative === "") {
return includeEqual;
}
if (pathApi.isAbsolute(relative)) {
return false;
}
if (relative === ".." || relative.startsWith(`..${pathApi.sep}`)) {
return false;
}
return true;
}

export function isPathEqualOrWithin(input: PathContainmentInput): boolean {
return isPathWithin(input, true);
}

export function isPathStrictlyWithin(input: PathContainmentInput): boolean {
return isPathWithin(input, false);
}
Loading