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
8 changes: 8 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ on:

jobs:
quality-checks:
# Private dev dependencies require NPM_TOKEN, which fork PRs cannot access.
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
Comment thread
Jercik marked this conversation as resolved.
Comment thread
Jercik marked this conversation as resolved.
runs-on: ubuntu-latest
timeout-minutes: 10
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@v6
Expand All @@ -24,6 +28,10 @@ jobs:
node-version-file: "package.json"
cache: "pnpm"

- name: Configure npm.j4k.dev auth
Comment thread
Jercik marked this conversation as resolved.
if: ${{ env.NPM_TOKEN != '' }}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ Info: When NPM_TOKEN is missing/empty, this step silently skips and pnpm install --frozen-lockfile later fails with a cryptic registry auth error. The job-level if on line 13 already excludes fork PRs, so the only remaining case where NPM_TOKEN would be empty is a misconfigured repository secret — exactly when a fast, explicit failure helps the maintainer. release-npm.yml (lines 52–55) already does this with [ -z "${NPM_TOKEN}" ] && echo "NPM_TOKEN is required..." && exit 1. Consider matching that pattern here and in .github/workflows/dedupe-check.yml line 36 for consistency.

run: printf "registry=https://npm.j4k.dev/\n//npm.j4k.dev/:_authToken=%s\n" "${NPM_TOKEN}" > .npmrc

- name: Install dependencies
run: pnpm install --frozen-lockfile

Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/dedupe-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@ on:

jobs:
dedupe-check:
# Private dev dependencies require NPM_TOKEN, which fork PRs cannot access.
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
timeout-minutes: 5
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@v6
Expand All @@ -28,6 +32,10 @@ jobs:
node-version-file: "package.json"
cache: "pnpm"

- name: Configure npm.j4k.dev auth
if: ${{ env.NPM_TOKEN != '' }}
run: printf "registry=https://npm.j4k.dev/\n//npm.j4k.dev/:_authToken=%s\n" "${NPM_TOKEN}" > .npmrc

- name: Install dependencies
run: pnpm install --frozen-lockfile

Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/release-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ jobs:
registry-url: https://registry.npmjs.org
cache: "pnpm"

- name: Configure npm.j4k.dev auth
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ -z "${NPM_TOKEN}" ]; then
echo "NPM_TOKEN is required to install private dependencies." >&2
exit 1
fi
printf "registry=https://npm.j4k.dev/\n//npm.j4k.dev/:_authToken=%s\n" "${NPM_TOKEN}" > .npmrc

- name: Install dependencies
run: pnpm install --frozen-lockfile

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Running `worktree-remove`:

## Requirements

- Node.js ≥ 22.19.0
- Node.js ≥ 24.0.0
- Git with `git worktree` support
- Optional: set `WORKTREE_REMOVE_GIT_PATH` to override the git executable

Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
"LICENSE"
],
"type": "module",
"publishConfig": {
"registry": "https://registry.npmjs.org/"
},
"scripts": {
"build": "tsgo -p tsconfig.app.json",
"clean": "rm -rf dist *.tsbuildinfo",
Expand Down Expand Up @@ -70,7 +73,7 @@
"knip": "^6.6.3",
"oxfmt": "^0.46.0",
"oxlint": "^1.61.0",
"oxlint-config-j4k": "^2.3.0",
"oxlint-config-j4k": "2.3.0",
Comment thread
Jercik marked this conversation as resolved.
"oxlint-tsgolint": "^0.22.0",
"semantic-release": "^25.0.3",
"vitest": "^4.1.5"
Expand Down
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 9 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { createOutputWriter } from "./cli/output-writer.js";
import { removeBatch } from "./cli/remove-batch.js";
import { selectWorktrees } from "./cli/select-worktree.js";
import { ensureGitAvailable } from "./git/git-helpers.js";
import { createRemovalPolicy } from "./removal/removal-policy.js";
Comment thread
Jercik marked this conversation as resolved.

const shouldUseColor = process.stderr.isTTY && !process.env.NO_COLOR;
chalk.level = shouldUseColor ? 3 : 0;
Expand Down Expand Up @@ -63,13 +64,19 @@ const program = new Command()
const quiet = options.quiet ?? false;
const output = createOutputWriter({ dryRun, verbose, quiet });
const force = options.force === true;
const assumeYes = options.yes === true || dryRun;
const assumeYes = options.yes === true;

const isCi = process.env.CI !== undefined;
const interactiveSelection = options.interactive === true;
const promptsDisabled =
options.interactive === false || isCi || !process.stdin.isTTY || !process.stderr.isTTY;
const allowPrompt = !promptsDisabled;
const policy = createRemovalPolicy({
dryRun,
assumeYes,
force,
allowPrompt,
});

if (interactiveSelection && !allowPrompt) {
output.error(
Expand Down Expand Up @@ -98,10 +105,7 @@ const program = new Command()
}

await removeBatch(selectedTargets, {
dryRun,
assumeYes,
force,
allowPrompt,
policy,
output,
});
} catch (error: unknown) {
Expand Down
9 changes: 4 additions & 5 deletions src/cli/confirm-action.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import { confirm, exitWithMessage } from "../git/git-helpers.js";
import type { RemovalPolicy } from "../removal/removal-policy.js";

interface ConfirmActionOptions {
assumeYes: boolean;
allowPrompt: boolean;
dryRun: boolean;
policy: RemovalPolicy;
promptDisabledMessage: string;
}

export async function confirmAction(
message: string,
options: ConfirmActionOptions,
): Promise<boolean> {
if (options.assumeYes || options.dryRun) {
if (options.policy.shouldAutoConfirm()) {
return true;
}
if (!options.allowPrompt) {
if (!options.policy.allowPrompt) {
exitWithMessage(options.promptDisabledMessage);
}
return confirm(message);
Expand Down
27 changes: 9 additions & 18 deletions src/cli/remove-batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,16 @@ import { reportBatchResults } from "./report-batch-results.js";
import { resolveBatchTargets, type ResolvedTarget } from "./resolve-batch-targets.js";
import { getWorktreeInfo } from "../git/get-worktree-info.js";
import { exitWithMessage } from "../git/git-helpers.js";
import type { RemovalPolicy } from "../removal/removal-policy.js";
import { performWorktreeRemoval } from "../worktree/perform-worktree-removal.js";

interface BatchOptions {
dryRun: boolean;
assumeYes: boolean;
force: boolean;
allowPrompt: boolean;
policy: RemovalPolicy;
output: OutputWriter;
}

export async function removeBatch(targets: string[], options: BatchOptions): Promise<void> {
const { dryRun, assumeYes, force, allowPrompt, output } = options;
const { policy, output } = options;
const { mainPath, worktrees } = getWorktreeInfo();
const invocationCwd = process.cwd();

Expand All @@ -45,17 +43,15 @@ export async function removeBatch(targets: string[], options: BatchOptions): Pro
if (dirtyTargets.length > 0) {
const dirtyNames = dirtyTargets.map((r) => r.displayInfo.targetDirectoryName).join(", ");

if (force || assumeYes || dryRun) {
if (policy.shouldWarnInsteadOfPrompt()) {
output.warn(
`${dirtyTargets.length} worktree${dirtyTargets.length > 1 ? "s have" : " has"} uncommitted changes: ${dirtyNames}`,
);
} else {
const proceed = await confirmAction(
`${dirtyTargets.length} worktree${dirtyTargets.length > 1 ? "s have" : " has"} uncommitted changes (${dirtyNames}). Remove anyway?`,
{
allowPrompt,
assumeYes,
dryRun,
policy,
promptDisabledMessage:
"Worktrees have uncommitted changes. Re-run with --yes, --force, or --dry-run to proceed in non-interactive mode.",
},
Expand Down Expand Up @@ -85,14 +81,12 @@ export async function removeBatch(targets: string[], options: BatchOptions): Pro
})),
invocationCwd,
mainPath,
dryRun,
dryRun: policy.dryRun,
output,
});

const confirmed = await confirmAction(confirmationMessage, {
allowPrompt,
assumeYes,
dryRun,
policy,
promptDisabledMessage:
"Confirmation required. Re-run with --yes or --dry-run to proceed in non-interactive mode.",
});
Expand All @@ -116,10 +110,7 @@ export async function removeBatch(targets: string[], options: BatchOptions): Pro
mainPath,
registeredPath: r.registeredPath,
directoryExistedInitially: r.directoryExists,
dryRun,
assumeYes,
force,
allowPrompt,
policy,
output: isSingleTarget ? output : prefixOutput(output, r.displayInfo.targetDirectoryName),
// Suppress the interactive trash-failure prompt for multi-target
// batches to prevent concurrent readline races on stdin. The user
Expand Down Expand Up @@ -151,7 +142,7 @@ export async function removeBatch(targets: string[], options: BatchOptions): Pro
name: resolved[index]?.displayInfo.targetDirectoryName ?? "unknown target",
result,
}));
reportBatchResults(entries, { dryRun, output });
reportBatchResults(entries, { dryRun: policy.dryRun, output });
}
}

Expand Down
26 changes: 26 additions & 0 deletions src/removal/removal-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export interface RemovalPolicy {
dryRun: boolean;
force: boolean;
allowPrompt: boolean;
shouldAutoConfirm: () => boolean;
shouldWarnInsteadOfPrompt: () => boolean;
}

interface CreateRemovalPolicyInput {
dryRun: boolean;
assumeYes: boolean;
force: boolean;
allowPrompt: boolean;
}

export function createRemovalPolicy(input: CreateRemovalPolicyInput): RemovalPolicy {
const autoConfirm = input.assumeYes || input.dryRun;

return {
dryRun: input.dryRun,
Comment thread
Jercik marked this conversation as resolved.
force: input.force,
allowPrompt: input.allowPrompt,
shouldAutoConfirm: (): boolean => autoConfirm,
shouldWarnInsteadOfPrompt: (): boolean => input.force || autoConfirm,
};
}
Comment thread
Jercik marked this conversation as resolved.
27 changes: 10 additions & 17 deletions src/worktree/perform-worktree-removal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { OutputWriter } from "../cli/output-writer.js";
import { unregisterWorktree } from "../git/unregister-worktree.js";
import { directoryExists } from "../fs/check-directory-exists.js";
import { trashDirectory } from "../fs/trash-directory.js";
import type { RemovalPolicy } from "../removal/removal-policy.js";

interface PerformWorktreeRemovalInput {
status: string;
Expand All @@ -11,10 +12,7 @@ interface PerformWorktreeRemovalInput {
mainPath: string;
registeredPath: string | undefined;
directoryExistedInitially: boolean;
dryRun: boolean;
assumeYes: boolean;
force: boolean;
allowPrompt: boolean;
policy: RemovalPolicy;
output: OutputWriter;
/** Suppress the interactive trash-failure prompt without enabling destructive
* fallback. Used by batch mode to prevent concurrent readline races. When
Expand All @@ -38,10 +36,7 @@ export async function performWorktreeRemoval(
mainPath,
registeredPath,
directoryExistedInitially,
dryRun,
assumeYes,
force,
allowPrompt,
policy,
output,
skipTrashFailurePrompt = false,
} = parameters;
Expand All @@ -50,11 +45,11 @@ export async function performWorktreeRemoval(

const directoryExistsBefore = await directoryExists(targetPath);
let movedToTrash = false;
let forceUnregister = force;
let forceUnregister = policy.force;
let unregisterFailed = false;

if (directoryExistsBefore) {
if (dryRun) {
if (policy.dryRun) {
output.info(`Would move '${targetPath}' to trash.`);
} else {
output.info("Moving directory to trash...");
Expand All @@ -63,20 +58,18 @@ export async function performWorktreeRemoval(
movedToTrash = true;
output.info("Directory moved to trash.");
} else if (registeredPath) {
if (skipTrashFailurePrompt && !force) {
if (skipTrashFailurePrompt && !policy.force) {
output.error(
`Could not move directory '${targetDirectoryName}' to trash: ${trashResult.reason}. Re-run with --force to allow Git to permanently delete it.`,
);
return { status: "failed" };
}
const proceed = force
const proceed = policy.force
? true
: await confirmAction(
`Could not move directory '${targetDirectoryName}' to trash: ${trashResult.reason}. Proceed with unregistering anyway? (Git may permanently delete it)`,
{
allowPrompt,
assumeYes,
dryRun,
policy,
promptDisabledMessage:
"Trash move failed. Re-run with --yes or --force to proceed in non-interactive mode.",
},
Expand All @@ -88,7 +81,7 @@ export async function performWorktreeRemoval(
// User confirmed git may permanently delete the directory, so force
// the unregister to handle dirty worktrees.
forceUnregister = true;
if (force || assumeYes) {
if (policy.shouldWarnInsteadOfPrompt()) {
output.warn(
`Could not move directory to trash: ${trashResult.reason}. Git may permanently delete it.`,
);
Expand All @@ -101,7 +94,7 @@ export async function performWorktreeRemoval(
}

if (registeredPath) {
if (dryRun) {
if (policy.dryRun) {
output.info(`Would unregister '${registeredPath}' from Git.`);
} else {
output.info("Unregistering from Git...");
Expand Down