Skip to content

fix(hooks): register Entire with Lefthook instead of owning its hook files - #2388

Open
peyton-alt wants to merge 19 commits into
mainfrom
fix/lefthook-integration
Open

peyton-alt wants to merge 19 commits into
mainfrom
fix/lefthook-integration

Conversation

@peyton-alt

@peyton-alt peyton-alt commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

https://entire.io/gh/entireio/cli/trails/1314

The bug

Lefthook regenerates every file in .git/hooks from its own config, gated by a checksum of that config — and it does so at the top of whatever hook fires next, typically the pre-commit of the very commit being made. Entire's turn-start self-heal (EnsureSetup → reinstall if not installed) therefore always loses the race:

  • lefthook install -f (what Lefthook's own npm postinstall runs) takes the hook back;
  • so does any lefthook.yml edit, via the checksum;
  • and the reclaim lands during the commit, not in some window before the next agent turn.

The user-visible symptom is #2264: entire status names a checkpoint destination while no hook exists to reach it. #1349 is the second-order damage — once Entire and Lefthook have fought over a hook, <hook>.old is left behind and Lefthook then fails every later sync with can't rename pre-push to pre-push.old - file already exists.

The fix

Stop wanting the file.

Artifact Purpose
entire-lefthook.yml Entire's own Lefthook config, declaring a script per hook
extends: entry in lefthook-local.yml the one line that makes Lefthook load it
.lefthook-local/<hook>/entire.sh the hook bodies, run by Lefthook

Lefthook resolves extends at run time, so this takes effect with no lefthook install and no user action, and Lefthook's own config is never touched.

Hook-file ownership follows from that. Once Lefthook dispatches Entire, Entire owning the file too runs every hook twice (its own copy, then Lefthook's launcher via the chain call — measured: 2× prepare-commit-msg, commit-msg, post-commit per commit). So:

CheckHookDelivery is now the single question entire status and entire doctor ask, and in a Lefthook repo it is answered by the registration rather than by .git/hooks/*.

Also corrected

detectHookManagers was wrong in both directions and gave users false advice:

  • pre-commit 4.6.2, Overcommit 0.73.0, hk 1.58.1 all overwrite Entire's hooks when they install (verified — pre-commit prints "Use -f to use only pre-commit" and saves .pre-commit.legacy; Overcommit moves old hooks aside). They were all flagged as harmless.
  • hk 2.x on Git 2.54+ is the exception: it installs Git's config-based hooks (hook.<name>.command), which git runs alongside .git/hooks/* (verified with hk 2.1.0 on Git 2.55 — both fire, and re-running hk install leaves Entire's files alone). entire enable warns about hk only on older Git, an unknown version, or when an hk legacy shim is already installed; otherwise it prints a no-action note.
  • Lefthook now needs no user action at all, so telling people to re-run entire enable is wrong. Only Lefthook reclaims continuously, which is why only Lefthook gets a config integration.

Safety

  • Every file Entire did not write is backed up to <name>.pre-entire, never destroyed; an existing backup is never buried (ErrLefthookArtifactBlocked).
  • lefthook-local.yml shadows lefthook-local.toml, so Entire refuses to create it beside a non-YAML local config (ErrLefthookLocalConfigUnwritable) and keeps its native hooks instead.
  • The local config is edited as a yaml.Node, so comments and key order survive.
  • Uninstall removes only artifacts carrying Entire's marker, takes the exclude block back out, and prunes the (empty) script directories.
  • Artifacts are generated and per-clone, so they are ignored via .git/info/exclude, not the user's .gitignore. A partial block from an older version repairs itself.

Verification

Against real lefthook 2.1.10 with the real entire binary:

  • hooks dispatch exactly once per commit (prepare-commit-msg, commit-msg, post-commit — 1 each), and the user's own Lefthook commands still run;
  • the mid-fight state (Entire's hook chaining to Lefthook's displaced launcher) was reproduced, measured at 2× per hook, and reconciled back to 1× by entire doctor --force;
  • entire status reports Git hooks · via Lefthook;
  • on a fresh repo all three artifacts are ignored and the worktree is clean;
  • mise run check passes. The two dedicated_rejected_by_fetch_owner subtests in TestRunStatus_CheckpointPushDisabledDestinations fail identically on origin/main (e8780c3) — pre-existing, from fix(status): report disabled checkpoint pushing #2337.

LEFTHOOK=0 skips Entire too

Lefthook's launcher exits on LEFTHOOK=0 before reading any config (its rc: file is sourced after that exit), so no registration can opt out; escaping it would mean owning the hook files again, which is #1349. A commit made under it gets no trailer; a push made under it defers checkpoints to the next push. status / doctor / status --json (hooks_skipped_by) now warn while it's set, and the counter drops its next-push promise. The endless carry-forward such a commit caused is fixed separately in #2577 (also on main).

Follow-ups (not in this PR)

  • Attributing a hook-skipped commit's turn to that commit (needs trailer-less checkpoint linking and a product call on attribution).
  • Hand-edited Entire hooks: every install path (enable -f, doctor --force, native reinstall, and Lefthook reconcile) treats a hook carrying Entire's marker as Entire-owned and replaces it without a backup. Protecting user edits to Entire's own hook would be one change across all of them (trail finding 01M38P8P6M1S, dismissed here for that reason).

Size

+1,413 / −60 — roughly 890 production, 490 test, 30 docs. This replaces the Lefthook half of #2343 (+6,807) and supersedes draft #2365 (+5,025).

🤖 Generated with Claude Code


Note

Medium Risk
Changes how Git hooks are installed and diagnosed across Lefthook and native paths; mistakes could miss checkpoint capture or double-run hooks, but behavior is heavily tested and fails closed on ambiguous local config.

Overview
Adds first-class Lefthook integration so checkpoint hooks survive Lefthook reclaiming .git/hooks/* on every run (#2264, #1349). Instead of reinstalling native wrappers, Entire writes entire-lefthook.yml, hook scripts under .lefthook-local/, and an extends: entry in lefthook-local.yml (YAML-only; refuses to shadow lefthook-local.toml). Native install skips hooks Lefthook already owns, reconciles double-dispatch and stale <hook>.old backups, and ignores generated files via .git/info/exclude.

CheckHookDelivery is now what entire status (human + JSON: hooks_deliver, hooks_manager) and entire doctor use—Lefthook repos are judged on registration, not hook file contents. Doctor can re-register or reconcile with --force. Uninstall removes Lefthook artifacts as well as native hooks. EnsureSetup runs Lefthook registration before native install.

Hook-manager detection warnings are corrected: Lefthook gets a “no action” note; pre-commit, Overcommit, and hk are warned as install-time overwriters (not harmless).

Reviewed by Cursor Bugbot for commit 2a36c3e. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 6 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 2a36c3e. Configure here.

return "# " + lefthookOwnedMarker + "\n" +
"# Managed by Entire. Remove the extends entry in your Lefthook local\n" +
"# config to detach; edits here are overwritten.\n" + string(out), nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Post-rewrite hangs under Lefthook

High Severity

Lefthook scripts do not get a closing stdin unless use_stdin is set, but PostRewrite reads git's old/new SHA mappings with a scanner until EOF. Amend and rebase then block forever on that read, so session remapping never finishes and the git operation never returns.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2a36c3e. Configure here.

Comment thread cmd/entire/cli/doctor.go Outdated
fmt.Fprintln(w, " Run `entire doctor --force` to apply it.")
return nil
}
if _, err := strategy.EnsureLefthookIntegration(ctx, false); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Doctor ignores hook path setting

Medium Severity

checkGitHooks always calls CheckHookDelivery and EnsureLefthookIntegration with absolutePath false. When absolute_git_hook_path is on, doctor treats current absolute-path scripts as stale, reports they are not delivering, and --force rewrites them to a PATH lookup that the next EnsureSetup writes back.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2a36c3e. Configure here.

default:
return HookDelivery{Manager: LefthookManagerName,
Reason: "Entire is not registered with Lefthook, so Lefthook's hooks do not run it."}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Status misses native Lefthook fallback

Medium Severity

When a repo is Lefthook-managed but the integration is declined (non-YAML local config), Entire keeps native hooks, yet CheckHookDelivery always reports they are not delivering. entire status then claims checkpoints are not captured, and entire doctor --force tries a registration that cannot succeed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2a36c3e. Configure here.

// Last: this is what makes Lefthook load any of the above.
if _, err := ensureExtendsEntry(root); err != nil {
return 0, err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Failed register leaves dirty artifacts

Medium Severity

EnsureLefthookIntegration writes entire-lefthook.yml and .lefthook-local/**/entire.sh before the extends entry, then excludeArtifacts never runs if that entry is refused. In a repo with a non-YAML local config those files stay untracked and unignored on every turn, which is the dirty-worktree problem the exclude block was added to prevent.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2a36c3e. Configure here.

seq := mapping.Content[found+1]
if seq.Kind != yaml.SequenceNode {
return nil, nil, errors.New("extends must be a sequence")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Scalar extends aborts setup

Medium Severity

extendsSequence rejects a scalar extends value. Lefthook accepts both a string and a list, so a lefthook-local.yml that uses the string form makes EnsureLefthookIntegration fail, and EnsureSetup then errors on every agent turn instead of appending Entire's config.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2a36c3e. Configure here.

paths.ClearWorktreeRootCache()
t.Cleanup(paths.ClearWorktreeRootCache)
return dir
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Lefthook tests skip git isolation

Medium Severity

newLefthookRepo inits a temp repo and then exercises git rev-parse --git-path hooks via reconcile and ReinstallGitHooks without testutil.IsolateGitConfigEnv. A host core.hooksPath (or other global git config) can make those tests read and rewrite the developer's real hooks directory.

Fix in Cursor Fix in Web

Triggered by learned rule: Tests shelling out to git CLI must call testutil.IsolateGitConfigEnv(t)

Reviewed by Cursor Bugbot for commit 2a36c3e. Configure here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

One or more issues must be addressed before approval.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds durable Lefthook integration so Entire hooks survive Lefthook refreshes, while improving hook-health reporting and native fallback behavior.

Changes:

  • Registers Entire through Lefthook configuration and generated scripts.
  • Reconciles legacy hook conflicts and updates installation/uninstallation.
  • Adds status, doctor, detection, and regression-test coverage.
File summaries
File Description
cmd/entire/cli/strategy/lefthook_test.go Updated as part of this pull request.
cmd/entire/cli/strategy/hooks.go Updated as part of this pull request.
cmd/entire/cli/strategy/hook_managers.go Updated as part of this pull request.
cmd/entire/cli/strategy/hook_managers_test.go Updated as part of this pull request.
cmd/entire/cli/strategy/common.go Updated as part of this pull request.
cmd/entire/cli/status.go Updated as part of this pull request.
cmd/entire/cli/status_test.go Updated as part of this pull request.
cmd/entire/cli/setup.go Updated as part of this pull request.
cmd/entire/cli/doctor.go Updated as part of this pull request.
CLAUDE.md Updated as part of this pull request.
Review details

Suppressed comments (9)

cmd/entire/cli/doctor.go:641

  • This branch is selected solely by the presence of a Lefthook config, so entire doctor now reports a never-enabled Lefthook repository as NOT DELIVERING, and doctor --force writes Entire's config/scripts and extends entry into it. The native-hook path below deliberately avoids this takeover with settings.IsSetUpAny; apply the same opt-in rule (while preserving the existing stale-hook handling) before repairing Lefthook integration.
	if delivery := strategy.CheckHookDelivery(ctx, false); delivery.Manager == strategy.LefthookManagerName {
		if delivery.OK {

cmd/entire/cli/setup.go:2671

  • This new cleanup step is unreachable when Lefthook artifacts are the only remaining Entire state: runUninstall's earlier "not installed" return does not count entire-lefthook.yml, the extends entry, or scripts. After a partial uninstall or manual .entire deletion, users therefore cannot reach this removal and generated artifacts remain. Include a read-only integration-presence check in the uninstall guard and summary.
	// The Lefthook registration is separate from the hook files, so uninstall
	// has to take both. Only artifacts carrying Entire's marker are removed.
	lefthookRemoved, err := strategy.RemoveLefthookIntegration(ctx)
	if err != nil {
		p.stepFailed("Failed to remove the Lefthook integration")

cmd/entire/cli/strategy/common.go:1846

  • The integration is only registered from EnsureSetup; entire configure --force and the absolute-path update path call InstallGitHook directly. On an upgraded Lefthook repo whose registration is missing or stale, those commands therefore write native wrappers over Lefthook's launcher, recreating the ownership fight until the next agent turn. Route all hook installation through this coordinator (or call it from those setup paths).
	if _, err := EnsureLefthookIntegration(ctx, absolute); err != nil {
		if errors.Is(err, ErrLefthookLocalConfigUnwritable) {
			return nil
		}
		return fmt.Errorf("failed to register Entire with Lefthook: %w", err)

cmd/entire/cli/strategy/hook_managers.go:88

  • When a Lefthook main config is present alongside lefthook-local.toml/.toml, EnsureLefthookIntegration deliberately declines and keeps native hooks, so Lefthook can still reclaim them on its next install. This warning nevertheless says No action needed, leaving the user with a non-durable setup and no instruction to migrate/edit the local config. The warning needs to reflect the declined integration (or be emitted only when the registration actually succeeds).
		case LefthookManagerName:
			// Entire registers itself in Lefthook's own config, so Lefthook's
			// regenerations no longer remove it and there is nothing for the
			// user to do. See EnsureLefthookIntegration.
			fmt.Fprintf(&b, "Note: %s detected (%s)\n\n", m.Name, m.ConfigPath)
			fmt.Fprintf(&b, "  %s regenerates Git hooks whenever its config changes or it runs.\n", m.Name)
			fmt.Fprintf(&b, "  Entire registers itself in %s's own configuration instead of owning\n", m.Name)
			fmt.Fprintf(&b, "  the hook files, so those regenerations no longer remove it.\n")
			fmt.Fprintf(&b, "  No action needed.\n\n")

cmd/entire/cli/strategy/lefthook.go:403

  • All read errors are treated as if the artifact were absent. For an unreadable file or symlink, the subsequent atomic write can therefore replace the existing path without creating the promised .pre-entire backup, losing the only recoverable copy of a foreign artifact. Ignore only os.IsNotExist; return other errors (or preserve the entry by renaming it) before claiming the path.
	data, err := osroot.ReadFileNoFollow(root, name)
	if err != nil || strings.Contains(string(data), lefthookOwnedMarker) {
		return nil //nolint:nilerr // absent or ours: nothing to displace

cmd/entire/cli/strategy/lefthook.go:423

  • A read failure is silently treated as a missing higher-precedence local config. WriteFileAtomicIn then replaces that path, including a symlink or an unreadable file, instead of refusing to touch the user's config. Only skip candidates for os.IsNotExist; propagate other errors so the integration fails closed rather than redirecting or discarding a local Lefthook configuration.
		body, readErr := osroot.ReadFileNoFollow(root, candidate)
		if readErr != nil {
			continue
		}

cmd/entire/cli/strategy/lefthook.go:496

  • removeExtendsEntry removes every extends: entire-lefthook.yml value, even when the entry predated Entire or was added by the user. ensureExtendsEntry returns early for an existing value, so that entry has no Entire marker, yet uninstall will still delete it and can change the user's Lefthook behavior. Remove only the marker-bearing node (or otherwise prove the integration owns the entry).
	kept := make([]*yaml.Node, 0, len(seq.Content))
	changed := false
	for _, item := range seq.Content {
		if item.Value == entireLefthookConfig {
			changed = true
			continue
		}

cmd/entire/cli/strategy/lefthook.go:410

  • The existing-backup check only treats a readable backup as blocking. If <name>.pre-entire is a symlink or unreadable, ReadFileNoFollow errors and the following atomic write replaces it, burying the earlier backup despite ErrLefthookArtifactBlocked being intended to prevent exactly that. Check for the backup's existence with a no-follow Lstat and fail closed on any non-ENOENT error.
	backup := name + GitHookBackupSuffix
	if _, err := osroot.ReadFileNoFollow(root, backup); err == nil {
		return fmt.Errorf("%w: %s already exists; remove it to let Entire reinstall %s",
			ErrLefthookArtifactBlocked, backup, name)
	}
	if err := jsonutil.WriteFileAtomicIn(root, backup, data, mode); err != nil {

cmd/entire/cli/strategy/lefthook.go:180

  • findLocalConfig supports alternate names such as lefthook-local.yaml and .lefthook-local.yml, but the exclude block ignores only lefthookLocalConfigNames[0]. When an alternate local config is the file being modified, the integration remains unignored and can show up as an untracked/committable generated change, contradicting the per-clone artifact handling described here. Add the selected local-config path (or consistently cover all supported local names) to the owned exclude block.
		"/" + entireLefthookConfig + "\n" +
		"/" + lefthookScriptDir + "/\n" +
		"/" + lefthookLocalConfigNames[0] + "\n" +
  • Files reviewed: 11/11 changed files
  • Comments generated: 5
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread cmd/entire/cli/strategy/lefthook.go Outdated
Comment on lines +146 to +150
if _, err := ensureExtendsEntry(root); err != nil {
return 0, err
}
if err := excludeArtifacts(ctx, repoRoot); err != nil {
return 0, err
Comment on lines 38 to 44
// Lefthook supports {.,}lefthook{,-local}.{yml,yaml,json,toml}
for _, prefix := range []string{"", "."} {
for _, variant := range []string{"", "-local"} {
for _, ext := range []string{"yml", "yaml", "json", "toml"} {
name := prefix + "lefthook" + variant + "." + ext
checks = append(checks, hookManager{"Lefthook", name, false})
checks = append(checks, hookManager{LefthookManagerName, prefix + "lefthook" + variant + "." + ext})
}
}
Comment thread cmd/entire/cli/strategy/lefthook.go Outdated
Comment on lines +316 to +320
if owned, ownErr := fileIsOwned(root, entireLefthookConfig); ownErr == nil && owned {
if err := osroot.RemoveNoSymlinks(root, entireLefthookConfig); err != nil && !os.IsNotExist(err) {
return removed, fmt.Errorf("remove %s: %w", entireLefthookConfig, err)
}
removed++
Comment on lines +680 to +690
if LefthookManaged(repoRoot) {
current, checkErr := LefthookIntegrationCurrent(ctx, absolutePath)
switch {
case checkErr != nil:
return HookDelivery{Manager: LefthookManagerName,
Reason: fmt.Sprintf("Could not inspect Entire's Lefthook integration: %v", checkErr)}
case current:
return HookDelivery{OK: true, Manager: LefthookManagerName}
default:
return HookDelivery{Manager: LefthookManagerName,
Reason: "Entire is not registered with Lefthook, so Lefthook's hooks do not run it."}
Comment on lines +686 to +690
case current:
return HookDelivery{OK: true, Manager: LefthookManagerName}
default:
return HookDelivery{Manager: LefthookManagerName,
Reason: "Entire is not registered with Lefthook, so Lefthook's hooks do not run it."}
peyton-alt and others added 11 commits September 23, 2026 11:35
…files

Lefthook regenerates every file in .git/hooks from its own config, gated by
a checksum of that config, and it does so at the top of whatever hook fires
next — typically the pre-commit of the very commit being made. Entire's
turn-start self-heal therefore always loses the race: any `lefthook install
-f` (npm postinstall) or lefthook.yml edit silently takes the hook back, and
the next commit is not captured. #2264 is the symptom users see — `entire
status` naming a checkpoint destination while no hook existed to reach it.

Stop wanting the file. Entire writes a config of its own
(entire-lefthook.yml) declaring a script per hook, registers it with a single
`extends` entry in Lefthook's local config, and lets Lefthook invoke those
scripts. Lefthook resolves `extends` at run time, so this takes effect with
no `lefthook install` and no user action, and Lefthook's own config is never
touched.

Hook-file ownership follows from that: once Lefthook dispatches Entire,
Entire owning the file too would run every hook twice, so the native install
skips any hook carrying Lefthook's launcher and reconcileHookFiles moves a
launcher Entire had displaced back over its own hook. It also clears the
<hook>.old backups that otherwise make every later Lefthook sync fail with
"can't rename pre-push to pre-push.old" (#1349). A hook Lefthook has not
taken over is still installed natively: in a repo where nobody ran `lefthook
install`, Entire's own hooks are the only delivery there is.

CheckHookDelivery is now the single question `entire status` and `entire
doctor` ask, and in a Lefthook repo it is answered by the registration rather
than by .git/hooks/*. Status gained a delivery line plus hooks_deliver /
hooks_manager / hooks_deliver_reason in --json; `doctor --force` re-registers
and reconciles. detectHookManagers' advice was wrong in both directions and
is corrected: pre-commit 4.6.2, Overcommit 0.73.0 and hk 1.58.1 all overwrite
Entire's hooks at install (verified), and Lefthook no longer needs any user
action at all.

Verified end-to-end against lefthook 2.1.10: hooks dispatch exactly once per
commit, the user's own Lefthook commands keep running, the three artifacts
are ignored via .git/info/exclude, and uninstall removes only what Entire
wrote.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M29BM8KGTDZXD1N3PJ3HGQFW
EnsureLefthookIntegration wrote its six artifacts and only then reached
ensureExtendsEntry, so a repo with a non-YAML lefthook-local.* — the
documented ErrLefthookLocalConfigUnwritable case — got entire-lefthook.yml
and .lefthook-local/ written before the refusal, and excludeArtifacts never
ran. Since ensureLefthookIntegrationIfManaged treats that refusal as a
decision rather than a failure, it recurred on every turn rather than
resolving itself: six untracked, un-excluded files in the worktree
permanently, which is the accidental-commit risk this file's own comments
warn about.

Two changes, covering two different failures. The local config is probed
before anything is written, so declining to shadow it now means touching
nothing at all. And the exclude entries go in while the paths are still
empty — they name paths, not existing files, so writing them first costs
nothing and a write that dies part way through (a blocked artifact path, a
full disk) can no longer leave anything unignored.

Verifying that against real lefthook surfaced a second bug in the same repo
shape: `doctor --force` exited with a raw "lefthook local config is not
YAML" and `entire status` reported "Checkpoints are NOT being captured" — in
a repo where Entire's own hooks are installed and firing. A declined
integration is permanent, so there was no repair coming and no fix to offer.
CheckHookDelivery now judges such a repo by its native hooks and reports
HookDelivery.Declined, which doctor prints and status --json exposes as
hooks_lefthook_declined, so the repo reads as working while still saying why
Lefthook is not the one carrying it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M29CCR921GJWJSAJ1PB1WD04
…l's reverse order

Two review findings on the Lefthook integration.

The turn-start step ran on every prompt with no gate, on a path EnsureSetup
documents as synchronous and timed. Measured, the cost was not the file reads
it looked like: LefthookIntegrationCurrent spent 11.2ms resolving the git
common dir for the exclude check, and reconcileHookFiles another 13.0ms
resolving the hooks dir — two `git rev-parse` subprocesses, the first on a
currency check a single turn runs two or three times. The common dir is now
memoized per worktree root (a worktree's does not move while the process
lives, and only the path is cached — osroot.Shared already memoizes the root
and is what keeps a handle from outliving a deleted directory), and
reconcileHookFiles uses the already-cached GetHooksDir instead of the by-path
variant. The whole step goes from 23.6ms to 0.42ms, against ~10-14ms for the
worktree-root subprocess EnsureSetup already pays for. A non-Lefthook repo
still exits after eight stats.

RemoveLefthookIntegration deleted entire-lefthook.yml and its scripts before
dropping the `extends` entry naming them, so an interruption or a failed
config write left Lefthook's config pointing at a file that was gone — the
reverse of the install, which deliberately writes that entry last. It now
removes the entry first. Lefthook 2.1.10 tolerates a dangling extends (run,
install, validate and dump all succeed, and the user's own commands still
fire), so this is not repairing a break; it is declining to depend on another
tool's tolerance, and it fails in the better direction, since giving up at
that step now leaves the integration whole rather than half-deleted.

The test helper clears both process-global caches, which are keyed by a
directory each test has just left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M29DEVHFN0HX551G3RB0MCEM
…ook on Windows

Registering with Lefthook is not delivery. Git runs a hook only if the FILE
exists, and Lefthook generates one per hook it knew about at its last install;
Entire's config arrives through `extends` at run time, so a repo whose
lefthook.yml declares only pre-commit has no file for the other four. Every
artifact Entire owns can be present and correct while nothing dispatches
Entire at all — and `entire status` reported hooks_deliver true in exactly
that repo, which is the same false OK (#2264) this integration exists to
remove. CheckHookDelivery now asks the question git asks: is there a file
here, and does it lead to Entire — Lefthook's launcher, or Entire's own hook.
`doctor --force` fills the gaps after registering, which the native install
does by skipping the paths Lefthook already owns.

Recognising Lefthook's launcher is now structural rather than a search for
the word "lefthook". A false positive there is silent and permanent: the file
is restored over Entire's hook by reconcileHookFiles and then skipped forever
by installSkipsHook, so capture stops for that hook with nothing to repair
it. The test is Lefthook's template shape — the call_lefthook function
definition, plus the dispatch line for THIS hook — with a table covering the
near misses, including a hand-written hook that runs lefthook itself.

Windows was the one path in this integration that is not true by
construction: Entire's hooks reach Lefthook as `scripts` with a bash runner,
so Lefthook resolves bash through PATH and spawns it with a POSIX script on a
platform whose shell is not POSIX. Two tests now cover it, named for ci.yml's
windows-latest selector: one runs the rendered script under bash and checks
the hook's arguments survive, and one drives the whole chain with the real
Lefthook binary — git runs Lefthook's hook, Lefthook resolves the extends and
spawns Entire's script. The Windows job installs lefthook 2.1.10 so the
second is a test rather than a skip, and ENTIRE_TEST_REQUIRE_LEFTHOOK makes a
failed install fail the job instead of passing silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M29EJEGJGNNNFTYPX5DT5VYK
The Windows job's first run showed the Lefthook integration working there —
bash resolved, the script ran with the hook's arguments, and every assertion
in the lefthook-driven end-to-end test passed — but the test still failed, in
t.TempDir's cleanup:

  unlinkat ...\.git: The process cannot access the file because it is being
  used by another process

osroot.Shared memoizes a *os.Root per directory, so the worktree root and git
common dir this test opened stay open for the life of the process, and an
open handle stops Windows deleting the directory under it. The helper now
calls osroot.ResetShared alongside the two path caches it already cleared;
being registered after t.TempDir, LIFO runs it first.

Also ToSlash the recorder path interpolated into the stub's shell script:
bash keeps backslashes literal inside single quotes, so a Windows path would
have become a mangled filename rather than a directory walk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M29F1G8DD3NWCDJ44WFBP13R
…g no action needed

Two review findings, both about the paths where Entire declines to register.

hookManagerWarning printed "No action needed" for every Lefthook repo, without
checking whether Entire could actually register. Where it has declined, the
repo runs on native hooks that Lefthook reclaims — so the message was the same
false advice (#2263) this integration set out to remove, reintroduced in the
branch that replaced it. The Lefthook case now splits: a Note with "No action
needed" only when registration is possible, and otherwise a Warning naming the
reason and saying plainly that Lefthook reclaims the hooks it manages, Entire
reinstalls them on the next agent turn, and a commit made in between is not
captured.

ensureExtendsEntry wrote into whatever lefthook-local.* it found, with no check
for whether the repository carries that file. Lefthook documents it as personal
and gitignored, but a committed one is the team's file: adding Entire's entry
modifies something shared, and a developer who reverts it gets it back on the
next turn. Entire now declines (ErrLefthookLocalConfigTracked), reusing the
Declined channel that already reports the non-YAML refusal. The check runs only
where the entry would actually be ADDED, so a tracked config that already
carries it — committed by the team, or added by hand — is honoured and costs
nothing after the first install. One correction to the finding: the entry was
written once, not on every turn; ensureExtendsEntry is idempotent and only runs
when the currency check fails.

The tracked probe is settings.PathIsTracked rather than a second index scan.
probePathIsVersioned took a path but used it only to locate the repo, comparing
against the EntireSettingsLocalFile constant — a single-purpose helper whose
signature looked general — so it now takes the repo root and a repo-relative
name, and the Dir(Dir(path)) inference moved to the settings callers whose path
shape justifies it. Duplicating it would have been worse than untidy: a153514
and 6c4adc4 exist because an exact comparison misses a tracked file on a
case-insensitive volume, and the open-coded copy this replaces had reintroduced
exactly that bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M29H2FBXXRQ51MV5028Q0MDK
`entire doctor` called CheckHookDelivery and EnsureLefthookIntegration with a
hardcoded absolutePath=false while status passed the setting and EnsureSetup
resolved it. In a repo configured with absolute_git_hook_path, doctor
therefore compared the installed scripts against the bare-command rendering,
reported a working install as NOT DELIVERING, and under --force rewrote the
scripts and config down to a bare `entire` — silently breaking delivery for
the GUI git clients that setting exists to serve.

Fixing doctor alone would leave the footgun: this is exactly what
ReinstallGitHooks was introduced to prevent ("so callers outside this package
cannot open-code it and silently drop absolute_git_hook_path"), and the new
exported functions had reintroduced it. They now resolve the prefix from the
repository's settings themselves, and the parameter is gone, so no caller can
choose it wrongly. InstallGitHook keeps its own flag; only the question "what
does this repository's installed integration say" is resolved internally.

TestLefthookIntegration_HonoursAbsoluteGitHookPath pins it: an absolute-path
repo installs the absolute form, reads as current rather than needing repair,
and is byte-identical after a second install.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M29HECQX4J0E382N3R8HZBZ6
Main moved the strategy key-file list out of CLAUDE.md, so the rebase
dropped this entry with the conflict. Same text, new home.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entire-Checkpoint: 01M37SP78XWXMRXA3E7BCBDSAS
entire-lefthook.yml set source_dir_local so Lefthook would find Entire's
scripts. A config reached through extends merges into the user's, so this
overrode any source_dir_local the user had chosen: their own local scripts
stopped resolving and Lefthook failed every commit with "script does not
exist". Dropping the key alone would break Entire's scripts in those repos
instead.

Declare each hook as a command that runs Entire's script by path. {0} is
the hook's arguments joined unescaped, which is exactly what Lefthook
appends to a script's command line, so the scripts see the same "$@".
Existing installs no longer match the rendered config and are rewritten on
the next turn.

The real-Lefthook test now keeps a user script in a custom directory and
requires it to still run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entire-Checkpoint: 01M37SRW0CWQ3KAFVFJYM33BBK
git passes post-rewrite's old/new commit pairs on stdin. Lefthook hands a
job an empty stdin unless it sets use_stdin, so under Lefthook
PostRewrite read no pairs and returned: every amend and rebase silently
skipped the session remap. (It did not hang, as a review suggested;
Lefthook's null reader returns EOF at once.)

Set use_stdin on the post-rewrite command. The real-Lefthook test now
amends and requires the pairs to reach Entire; it fails without this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entire-Checkpoint: 01M37SSJC06Y3V7FEE647QHD6R
Entire-Checkpoint: 01M37X0CCQY73EJ6KKV5TB5EF0
@peyton-alt
peyton-alt force-pushed the fix/lefthook-integration branch from 024b6db to 5820bf6 Compare September 23, 2026 19:50
peyton-alt and others added 5 commits September 23, 2026 14:26
Entire-Checkpoint: 01M382M5H75WAH035FJBHZ7DWQ
Entire-Checkpoint: 01M388FDS46YXDYX0F9D6RQ5SW
Entire-Checkpoint: 01M38C4143ERYFH2TNVNGRZA88
With Entire's Git hooks not delivering, status warned that checkpoints
are not being captured but still told the user their pending checkpoints
"sync with your next 'git push origin'" — which #2264 calls out as a
promise that cannot happen, since no pre-push hook will run.

The unpushed counter now keeps the count and replaces the promise with
"won't sync until Entire's Git hooks run again" whenever hook delivery
fails. The pushing-disabled phrasing already promised nothing and is
unchanged. Fixtures that assert the promise now install the hooks first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M38P4MC6TJMWSECRGGDDRA90
Lefthook's launcher exits on LEFTHOOK=0 before reading any config, so
once Entire runs as a Lefthook job it is skipped along with the user's
own hooks. A commit made that way gets no checkpoint trailer and its
turn is attributed to no commit; nothing inside Lefthook can opt out of
it (its rc: file is sourced after the exit).

CheckHookDelivery now reports that as SkippedBy, keeping OK true since
nothing is broken. status warns beneath "Git hooks · via Lefthook" and
drops the next-push promise from the unpushed counter, status --json
carries hooks_skipped_by, and doctor adds a warning under its OK line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M38TYBGW4REG8Y69B2X662A8
hk 2.x installs Git's config-based hooks (hook.<name>.command) on Git
2.54+, which git runs alongside .git/hooks/* — verified with hk 2.1.0 on
Git 2.55: both hk's hooks and Entire's fire, and re-running hk install
leaves Entire's hook files alone. Only hk's legacy mode (older Git, or
--legacy) writes shims over them.

entire enable now warns about hk only when it will write shims: Git
older than 2.54, an unknown version, or an hk shim already in the hooks
directory. Otherwise it prints a note that no action is needed and names
--legacy as the mode that does replace Entire's hooks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M39YSN6R5DK81ZPXH3AWKKYH
peyton-alt and others added 2 commits October 19, 2026 14:46
Install now filters out the hooks Lefthook delivers before main's locked
installHooks runs, and uninstall adds the Lefthook removal to main's
GitHookRemoval count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entire-Checkpoint: 01M5B22A5EEPDNFFTKV5X87AHE
In a Lefthook repo .git/hooks/* are Lefthook's launchers, so the
uninstall summary listed no git hooks and a repo with only the Lefthook
registration read as not installed, leaving the extends entry behind.
Check Entire's Lefthook artifacts on their own and show them as a row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entire-Checkpoint: 01M5B27NP9BV35XK55A3XXWDS6
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants