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
36 changes: 34 additions & 2 deletions docs/hooks.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Hooks

Every worktree operation can run a project script before and after it. Put an executable-or-not
file at `.linchpin/hooks/<name>` and it runs at that point.
file at `.linchpin/hooks/<name>`, approve it with `linchpin wt trust`, and it runs at that
point.

```bash
# .linchpin/hooks/post-switch
Expand Down Expand Up @@ -55,10 +56,41 @@ deliberate:
The hook path is passed as an argument (`$1`) rather than interpolated into the script, so a
path containing spaces or shell metacharacters stays data.

## Hooks must be trusted before they run

`.linchpin/hooks/` is committed, so hooks arrive with a `git clone` — unlike `.git/hooks`,
which git deliberately refuses to transfer for exactly this reason. Combined with sourcing,
that would make cloning a repository equivalent to running whatever it shipped: no execute bit,
no shebang, nothing in a diff marking the file as code that will run.

So a hook does nothing until this machine has approved it, the same way `direnv` and `mise`
handle `.envrc`:

```bash
linchpin wt trust # list this repo's hooks and their state
linchpin wt trust post-switch # approve one, after reading it
linchpin wt trust --all # approve every hook in the repo
linchpin wt trust post-switch --revoke # withdraw approval
```

An untrusted hook is skipped with a message naming the file and the command that would approve
it. The operation itself still succeeds — a blocked hook is not a failed switch.

**Approval covers the contents, not the filename.** Trust is recorded against a hash of the
file, so editing a trusted hook withdraws its trust automatically and it has to be reviewed
again. Pulling a branch that changes a hook you trusted last week does not inherit that trust.

Approvals are per-machine and live outside the repository — at `$XDG_DATA_HOME/linchpin/trust.json`,
or `~/.local/share/linchpin/trust.json` by default, overridable with `LINCHPIN_TRUST_FILE`. A
repository cannot grant its own trust.

## Guardrails

- **A failing hook fails the operation.** That is intentional — a `pre-switch` that cannot
prepare the environment should stop the switch rather than let it half-happen.
- **Hooks run with your full environment and privileges.** They are ordinary shell scripts in
your repository; review them the way you would review any other code you run.
your repository; review them before trusting them, the way you would review any other code
you run.
- **A hook that runs says so.** Each one prints `Ran hook: <path>` to stderr, so sourcing a file
from the repo is never silent.
- **Keep them fast.** A hook on `post-switch` runs every time anyone changes branch.
176 changes: 168 additions & 8 deletions legacy/commands/wt.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,17 @@ const {
writeDefaultConfig
} = require('../lib/config');
const { ensurePluginLink, readExistingTarget } = require('../lib/symlink');
const { findHookFile, runHook } = require('../lib/hooks');
const { findHookFile, runHook: runHookRaw } = require('../lib/hooks');
const {
describeUntrustedHook,
hashHookFile,
isHookTrusted,
readTrustStore,
revokeHook,
trustFilePath,
trustHook
} = require('../lib/trust');
const { requireContained } = require('../lib/paths');

/** Environment types: base path getters for config init. */
const ENV_TYPE_BASES = Object.freeze({
Expand Down Expand Up @@ -108,6 +118,29 @@ function buildTargetPath(envType, site, contentDir, slug, wpEnvBase, linkName) {
return '';
}

/**
* Run a hook and say so.
*
* Every lifecycle call goes through here rather than through the raw runner, so
* two things are always true: a blocked hook explains itself and the command
* carries on, and a hook that *does* run names itself first. Sourcing a file
* from the repo is never silent in either direction.
*
* Both lines go to stderr — stdout carries the worktree path that
* `cd "$(linchpin wt switch)"` consumes.
*/
function runHook(basePath, hookName, env, options) {
const result = runHookRaw(basePath, hookName, env, options);

if (result.blocked) {
process.stderr.write(`${result.reason}\n`);
} else if (result.ran) {
process.stderr.write(`Ran hook: ${result.hookFile}\n`);
}

return result;
}

function runWt(argv, options = {}) {
const cwd = options.cwd || process.cwd();
const command = argv[0] || 'help';
Expand Down Expand Up @@ -144,6 +177,8 @@ function runWt(argv, options = {}) {
return commandLink(cwd, argv.slice(1));
case 'invoke':
return commandInvoke(cwd, argv.slice(1));
case 'trust':
return commandTrust(cwd, argv.slice(1));
case 'config':
return commandConfig(cwd, argv.slice(1));
case 'help':
Expand Down Expand Up @@ -256,6 +291,36 @@ async function commandSwitch(cwd, argv) {
LINCHPIN_ENVIRONMENT: environmentName
};

// A --force that replaces a real directory is the one destructive thing this
// command does, and the path it deletes comes from the committed config. Ask
// before doing it, and refuse rather than assume consent when there is no one
// to ask. `--yes` is the explicit, scriptable answer.
if (options.force && !options.dryRun) {
const existingTarget = readExistingTarget(targetPath);

if (existingTarget.exists && !existingTarget.isSymlink) {
if (!options.yes) {
if (!process.stdin.isTTY) {
throw new Error(
`Refusing to replace ${targetPath} without confirmation. ` +
`Re-run with --yes if you intend to delete it.`
);
}

const { confirm } = await import('@inquirer/prompts');
const approved = await confirm({
message: `Permanently delete ${targetPath} and replace it with a symlink?`,
default: false
});

if (!approved) {
process.stderr.write('Cancelled; nothing was changed.\n');
return 0;
}
}
}
}

if (!options.dryRun) {
runHook(basePath, 'pre-switch', switchEnv);
}
Expand Down Expand Up @@ -603,8 +668,10 @@ function commandCopy(cwd, argv) {
assertInLinkedWorktree(cwd, basePath);

const currentPath = getCurrentTopLevel(cwd);
const source = path.join(basePath, target);
const destination = path.join(currentPath, target);
// `target` is argv, and it feeds a recursive copy at both ends. Contained so
// `../../..` cannot read outside the base worktree or write outside this one.
const source = requireContained(basePath, target, 'the base worktree');
const destination = requireContained(currentPath, target, 'the current worktree');

if (!pathExists(source)) {
throw new Error(`'${target}' does not exist in base worktree.`);
Expand All @@ -629,8 +696,10 @@ function commandLink(cwd, argv) {
assertInLinkedWorktree(cwd, basePath);

const currentPath = getCurrentTopLevel(cwd);
const source = path.join(basePath, target);
const destination = path.join(currentPath, target);
// Same containment as `copy`: this creates a symlink and may unlink whatever
// sits at the destination, so neither end may leave its worktree.
const source = requireContained(basePath, target, 'the base worktree');
const destination = requireContained(currentPath, target, 'the current worktree');

if (!pathExists(source)) {
throw new Error(`'${target}' does not exist in base worktree.`);
Expand Down Expand Up @@ -665,14 +734,97 @@ function commandInvoke(cwd, argv) {
const hookFile = findHookFile(basePath, hookName);

if (!hookFile) {
// Also the answer when the name escaped the hooks directory or resolved
// through a symlink out of it — both are "no such hook" from here.
throw new Error(`Hook '${hookName}' does not exist in .linchpin/hooks.`);
}

runHook(basePath, hookName);
const result = runHook(basePath, hookName);

if (result.blocked) {
// The wrapper already explained why on stderr; the exit code is what a
// script or an agent reads.
return 1;
}

process.stdout.write(`Ran ${hookFile}\n`);
return 0;
}

/**
* `linchpin wt trust` — review and approve the hooks this repo ships.
*
* Approval is recorded against the hook's **contents**, so editing a trusted
* hook withdraws its trust automatically and it must be reviewed again.
*/
function commandTrust(cwd, argv) {
const basePath = getBaseWorktreePath(cwd);
const hooksDir = path.join(basePath, '.linchpin', 'hooks');
const revoking = argv.includes('--revoke');
const all = argv.includes('--all');
const name = argv.find((token) => !token.startsWith('-'));

const present = listRepoHooks(hooksDir);

if (!name && !all) {
if (present.length === 0) {
process.stdout.write(`No hooks in ${hooksDir}\n`);
return 0;
}

process.stdout.write(`Hooks in ${hooksDir}\n`);
for (const hookName of present) {
const hookFile = path.join(hooksDir, hookName);
const state = isHookTrusted(hookFile) ? 'trusted' : 'UNTRUSTED';
process.stdout.write(` ${state.padEnd(10)} ${hookName}\n`);
}
process.stdout.write(`\nTrust file: ${trustFilePath()}\n`);
return 0;
}

const targets = all ? present : [name];

if (targets.length === 0) {
throw new Error(`No hooks found in ${hooksDir}.`);
}

for (const hookName of targets) {
const hookFile = findHookFile(basePath, hookName);

if (!hookFile) {
throw new Error(`Hook '${hookName}' does not exist in .linchpin/hooks.`);
}

if (revoking) {
const removed = revokeHook(hookFile);
process.stdout.write(`${removed ? 'Revoked' : 'Was not trusted'}: ${hookName}\n`);
continue;
}

const digest = trustHook(hookFile);
if (!digest) {
throw new Error(`Could not record trust for ${hookFile}.`);
}

process.stdout.write(`Trusted ${hookName} (${digest.slice(0, 12)})\n`);
}

return 0;
}

/** Hook filenames in a repo's hooks directory, sorted. Missing directory is empty. */
function listRepoHooks(hooksDir) {
try {
return fs
.readdirSync(hooksDir, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort();
} catch (_error) {
return [];
}
}

async function runConfigInitPrompts(basePath, options = {}) {
const { confirm, input, select } = await import('@inquirer/prompts');
const CONFIG_FILE_NAME = '.linchpin.json';
Expand Down Expand Up @@ -1067,6 +1219,7 @@ function parseSwitchArgs(argv) {
let environment = null;
let force = false;
let dryRun = false;
let yes = false;

for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
Expand All @@ -1087,6 +1240,11 @@ function parseSwitchArgs(argv) {
continue;
}

if (token === '--yes' || token === '-y') {
yes = true;
continue;
}

if (token === '--dry-run') {
dryRun = true;
continue;
Expand All @@ -1104,7 +1262,8 @@ function parseSwitchArgs(argv) {
ref,
environment,
force,
dryRun
dryRun,
yes
};
}

Expand Down Expand Up @@ -1196,7 +1355,7 @@ function printWtHelp() {
process.stdout.write(`Usage:\n`);
process.stdout.write(` linchpin wt ls [--json]\n`);
process.stdout.write(` linchpin wt current [--link] [--env <name>]\n`);
process.stdout.write(` linchpin wt switch [worktree|branch] [--env <name>] [--force] [--dry-run]\n`);
process.stdout.write(` linchpin wt switch [worktree|branch] [--env <name>] [--force] [--yes] [--dry-run]\n`);
process.stdout.write(` linchpin wt new [name]\n`);
process.stdout.write(` linchpin wt get <branch>\n`);
process.stdout.write(` linchpin wt extract\n`);
Expand All @@ -1209,6 +1368,7 @@ function printWtHelp() {
process.stdout.write(` linchpin wt copy <path>\n`);
process.stdout.write(` linchpin wt link <path>\n`);
process.stdout.write(` linchpin wt invoke <hook>\n`);
process.stdout.write(` linchpin wt trust [<hook>|--all] [--revoke]\n`);
process.stdout.write(` linchpin wt config init [--plugin-slug <slug>] [--force] [--no-interactive]\n`);
process.stdout.write(` linchpin wt config show\n`);
process.stdout.write(`\n`);
Expand Down
31 changes: 30 additions & 1 deletion legacy/lib/hooks.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
const fs = require('node:fs');
const path = require('node:path');
const { runCommand } = require('./shell');
const { isContainedAfterLinks, resolveContained } = require('./paths');
const { describeUntrustedHook, isHookTrusted } = require('./trust');

/**
* Resolve `.linchpin/hooks/<name>`, or null when there is no such hook.
*
* `hookName` comes from argv via `wt invoke` and the result is sourced as
* bash, so the join is contained rather than plain — see src/core/paths.ts.
*/
function findHookFile(basePath, hookName) {
const hookFile = path.join(basePath, '.linchpin', 'hooks', hookName);
const hooksRoot = path.join(basePath, '.linchpin', 'hooks');
const hookFile = resolveContained(hooksRoot, hookName);

if (hookFile === null) {
return null;
}

if (!isContainedAfterLinks(hooksRoot, hookFile)) {
return null;
}

if (fs.existsSync(hookFile) && fs.statSync(hookFile).isFile()) {
return hookFile;
}
Expand All @@ -21,6 +39,17 @@ function runHook(basePath, hookName, env = {}, options = {}) {
};
}

// Fail closed: a committed hook runs only once this machine has approved
// these exact bytes. See legacy/lib/trust.js.
if (!isHookTrusted(hookFile)) {
return {
ran: false,
hookFile,
blocked: true,
reason: describeUntrustedHook(hookFile)
};
}

const execOptions = {
env: {
...process.env,
Expand Down
Loading