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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
node_modules/
dist/
.worktrees
docs/
tasks/
41 changes: 21 additions & 20 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,26 +35,27 @@ bun run format # Prettier
- Every `@clack/prompts` call must check `p.isCancel()` and exit gracefully.
- `shell.ts` reads stdout and stderr concurrently with `Promise.all` to avoid pipe deadlocks.

## Comment discipline

**Default: write zero comments.** Well-named identifiers + control flow explain WHAT. This project follows the global "no comments unless genuinely complex" rule **strictly** — stricter than the global default.

**Fix rationale belongs in commit messages, not code.** "We added X to prevent Y outage" is commit-message content. It does NOT go in the code — comments drift from the implementation as the code evolves, commit messages and PR descriptions don't.

**The only code comments that earn their keep are footgun warnings** — ones that save a future dev from a specific non-obvious trap AND aren't findable from git blame. Examples that pass the bar:

- `// Bun.spawnSync returns null exitCode on timeout kill.` (runtime quirk)
- `// Constant-time compare prevents timing side-channel on hash compare.` (security invariant the call site alone doesn't communicate)
- `// POSIX setsid(): survives terminal close so a slow download isn't SIGHUPed.` (cross-platform behavior note)

**Anti-patterns — NEVER write any of these** (concrete examples from real commits that violated this rule):

1. **Paraphrasing the next line** — `// Bump throttle on transient network failures so we don't burn the GitHub API quota` above `recordCheckCompleted();`. The function name already says this.
2. **JSDoc-style docblocks for internal helpers** — `// true=exists, false=ENOENT, null=non-ENOENT error logged; caller must bail` above `function checkExists(...): boolean | null`. The return type plus null-checks at call sites already communicate the contract.
3. **Multi-line fix rationale** — any 2+ line comment explaining WHY a PR-level decision was made. That belongs in the commit message. If it's not findable from `git blame`, improve the commit message instead of polluting the code.
4. **Stacked WHY paragraphs** — back-to-back `// line 1 / // line 2 / // line 3` blocks. Treat 2 lines as a warning sign; 3+ lines is always wrong.

**Self-test before writing any comment**: remove it and re-read the function. Would a future reader (including future-me) be meaningfully more confused without it? If the answer is "no" or "barely" — delete the comment.
## Code comments

> **No comments in code. Rename or restructure instead.** Two exceptions, and each must fit on one line:
>
> 1. A `/** */` docstring on an exported symbol, stating a contract the signature cannot show.
> 2. A citation for a constraint the code cannot express: a URL, a spec section, an ADR or `docs/` path, or an issue number. The line carries the pointer, never the explanation.
>
> Everything else is banned. That includes any comment that explains what the code does, why it has its shape, what would break, or what you learned while writing it. Put that reasoning in the PR body, a test name, or an ADR.
>
> Existing comments in a file are not a style to match and not a license to add more. Leave them alone when you touch the file for another reason.
>
> Before reporting a code change done, print every added comment line that lacks a citation token and delete each comment it shows. The pattern is a net, not a parser: a printed line that is not a comment is a false positive to leave alone.
>
> ```bash
> CITED='https?://|docs/|ADR|#[0-9]+|§'
> git diff -U0 <base> -- '*.ts' '*.tsx' '*.js' '*.jsx' '*.go' '*.rs' '*.java' '*.kt' '*.swift' '*.c' '*.h' '*.cpp' '*.cs' \
> | grep -E '^\+([[:space:]]*(//|/\*|\* )|.*[[:space:]](//|/\*))' \
> | grep -vE "$CITED|^\+[[:space:]]*/\*\*.*\*/[[:space:]]*\$"
> git diff -U0 <base> -- '*.py' '*.sh' '*.zsh' '*.rb' '*.toml' '*.yml' '*.yaml' \
> | grep -E '^\+([[:space:]]*#|.*[[:space:]]#)' | grep -vE "$CITED|^\+#!"
Comment thread
bhagyamudgal marked this conversation as resolved.
> ```

## Dependencies

Expand Down
77 changes: 77 additions & 0 deletions docs/adr_auto_update_security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# ADR: auto-update security model

Status: accepted.

Worktree-cli self-updates by downloading compiled binaries from GitHub
releases in a background child process and swapping them in on the next
launch. The updater fetches over the network, writes executables, and runs
them, so every input is untrusted until verified.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## 1. Threat model

Attackers considered: a network adversary off GitHub origins, a compromised
CDN or release asset, a tampered SHA256SUMS file, a local user planting
symlinks in shared install dirs, and stale or rolled-back stages.
Non-goals: defending a machine whose running binary is already compromised,
and hiding version numbers.

## 2. Host pinning and redirects

Fetches go only to allowlisted GitHub hosts: api.github.com, github.com,
codeload.github.com, objects.githubusercontent.com,
release-assets.githubusercontent.com, and
github-releases.githubusercontent.com. The check compares URL.host and does
not require HTTPS. Redirects are followed manually so each hop's host is
validated before connecting. Authorization is stripped once the host differs
from the first URL's host and never re-added, so a chain that bounces back
to the starting host cannot re-attach the token; a same-host scheme change
does not strip it. Redirect refusals log the host only, because signed CDN
URLs can carry tokens in the query string, while the initial
disallowed-host refusal logs a truncated URL.

## 3. Size caps

Asset downloads are capped at 200 MB, which is headroom over the current
~50 MB binary, and oversized responses are rejected before verification.
Chunks stream to disk with the cap enforced as bytes arrive instead of
buffering the whole body in memory.

## 4. Release metadata and checksums

Release tags must match a strict version pattern before they are used in
paths or logs. The SHA256SUMS parser lowercases hex, skips blanks and
`#` comments, rejects BSD-tagged `SHA256 (file) = hex` lines, and treats
duplicate entries as tampering. Tamper (parsed but malformed sums) is a
distinct outcome from fetch errors: tamper escalates loudly and burns the
throttle, while fetch errors retry when transient. Retryable statuses are
5xx plus 403 and 429, which are the GitHub rate-limit signals; other 4xx are
permanent. In the background path, releases without SHA256SUMS fall back to
a self-hash recorded in the sidecar, which detects local stage-to-apply
corruption only, not upstream tampering; the foreground updater instead
proceeds without hash verification and says so.
Requests identify as worktree-cli and use GITHUB_TOKEN when present, since
authenticated calls get a far higher rate limit than anonymous ones.

## 5. Staging and apply integrity

Temp and sidecar files are pre-unlinked so writes cannot follow planted
symlinks. Verification runs before chmod and before the probe, because
executing an unverified binary is code execution. The probe requires the
staged binary to run `--version` successfully with version-shaped output,
because a hash match does not prove runnability, and it runs with
auto-update disabled so the probe cannot spawn grandchildren or consume a
stale stage. The sidecar writer is locked to the reader's version and hash
pattern so a future parser relaxation cannot turn a crafted tag into a hash
spoof. A stage older than the running version is discarded as stale, since a
foreground update may have raced a background check, and applying it would
silently downgrade. Hash comparison is constant-time to close the timing
side channel, and the sums object has a null prototype to block `__proto__`
pollution from a tampered file.

## 6. Throttle policy

A completed check burns the 24h throttle window on structural or permanent
outcomes, so a broken release does not cost a download or API call on every
launch. Transient outcomes keep retrying: a missing arch asset (the
maintainer may upload it later), transient sums errors, local hash I/O
errors, and sidecar or stage writes that fail for non-permission reasons.
47 changes: 47 additions & 0 deletions docs/comment_salvage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Comment salvage

Facts removed from code comments because the no-comments rule allows only
one-line citations. Each entry names the file and symbol it came from. Prune
this file by verifying each fact and either citing a source or deleting it.

## src/lib/editor.ts / resolveEditor

- clack's select returns string | symbol, but isCancel narrows the symbol
case above.

## src/lib/git.ts / selectWorktree

- p.select returns string | symbol, but isCancel above exits on symbol, and
library types do not narrow.

## src/lib/auto-update.ts / scheduleBackgroundUpdateCheck

- POSIX setsid(): survives terminal close so a slow download isn't SIGHUPed.
- Close parent's fd copy even if Bun.spawn throws synchronously (else fd
leak per launch).

## src/lib/auto-update.ts / probeBinaryRuns

- Bun.spawnSync returns null exitCode on timeout kill.

## src/lib/auto-update.ts / decodeProbeStream

- Emit a debuggable marker (not "") so a Bun API shape change is visible in
last-error.

## src/lib/config.ts / readConfigFile

- file.exists() can throw on stat errors.

## src/lib/config.ts / shouldAutoUpdate

- file.exists() can throw EACCES.

## src/lib/fs-utils.ts / classifyWriteError

- Walks cause chain for errno; EBUSY/ETXTBSY treated as permanent (file
locked/busy).

## src/lib/release.ts / withTimeout

- Drain the redirect body so keep-alive sockets don't pin across hops.
1 change: 0 additions & 1 deletion src/commands/internal-update-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ export const internalUpdateCheckCommand = command({
desc: "",
hidden: true,
handler: async () => {
// Detached child's stderr is redirected; catch so panics still hit last-error.
try {
await runBackgroundUpdateCheck();
} catch (error) {
Expand Down
6 changes: 3 additions & 3 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export const updateCommand = command({
printInfo(`Downloading ${assetName}...`);

const tmpPath = `${binaryPath}.update-tmp`;
// Pre-unlink to prevent symlink-follow in shared install dirs.
// docs/adr_auto_update_security.md §5
await safeUnlink(tmpPath);
const { error: dlError } = await tryCatch(
downloadAsset(asset, tmpPath)
Expand Down Expand Up @@ -150,7 +150,7 @@ export const updateCommand = command({
process.exit(EXIT_CODES.ERROR);
}

// Probe before rename — SHA match ≠ runnable; segfaults on libc/codesign mismatch.
// docs/adr_auto_update_security.md §5
const probe = probeBinaryRuns(tmpPath);
if (!probe.ok) {
await safeUnlink(tmpPath);
Expand All @@ -177,7 +177,7 @@ export const updateCommand = command({
process.exit(EXIT_CODES.ERROR);
}

// Invalidate pending stage + bump throttle to prevent silent downgrade on next launch.
// docs/adr_auto_update_security.md §5, §6
cleanupStagedArtifacts();
recordCheckCompleted();

Expand Down
4 changes: 0 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,13 @@ const META_FLAGS = new Set(["--version", "-v", "--help", "-h"]);
const FOREGROUND_UPDATE_SUBCOMMAND = "update";

function isMetaInvocation(): boolean {
// Match only the first positional arg so flag-as-value (e.g. `create my-feature -h`) still auto-updates.
const first = process.argv[2];
return first !== undefined && META_FLAGS.has(first);
}

function shouldSkipAutoUpdate(): boolean {
const first = process.argv[2];
if (first === INTERNAL_CHECK_SUBCOMMAND) return true;
// Skip for the foreground updater to avoid racing its own binary install.
if (first === FOREGROUND_UPDATE_SUBCOMMAND) return true;
return isMetaInvocation();
}
Expand All @@ -37,14 +35,12 @@ if (!shouldSkipAutoUpdate()) {
try {
applyPendingUpdate();
} catch (error) {
// Never crash the entry point — the user's command (including `worktree update`) must still run.
appendBackgroundCheckPanic(error);
const { DIM, RESET } = COLORS;
console.error(
`${DIM}worktree: auto-update apply failed unexpectedly — set WORKTREE_NO_UPDATE=1 to disable; see ~/.cache/worktree-cli/last-error${RESET}`
);
}
// Funnel async throws into the panic logger, not an unhandled rejection.
void scheduleBackgroundUpdateCheck().catch(appendBackgroundCheckPanic);
}

Expand Down
Loading