Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2567bfa
fix(hooks): register Entire with Lefthook instead of owning its hook …
peyton-alt Sep 11, 2026
24835e2
fix(hooks): leave the repo untouched when declining a Lefthook config
peyton-alt Sep 11, 2026
05d5da6
fix(hooks): cut the Lefthook turn-start cost, and uninstall in instal…
peyton-alt Sep 11, 2026
47c40ad
fix(hooks): require a hook file that reaches Entire, and verify Lefth…
peyton-alt Sep 11, 2026
3aa93ff
test(hooks): close memoized roots so Windows can clean up its temp repos
peyton-alt Sep 12, 2026
582495f
fix(hooks): decline a tracked Lefthook local config, and stop claimin…
peyton-alt Sep 12, 2026
3bffe2c
fix(hooks): read the hook prefix from settings, not from the caller
peyton-alt Sep 12, 2026
7159e48
docs: move the Lefthook key-file entry to checkpoint-implementation.md
peyton-alt Sep 23, 2026
4c8bb27
fix(hooks): stop overriding the user's Lefthook script directory
peyton-alt Sep 23, 2026
78b35d0
fix(hooks): give Entire's post-rewrite its stdin under Lefthook
peyton-alt Sep 23, 2026
5820bf6
fix(hooks): close Lefthook integration review gaps
peyton-alt Sep 23, 2026
7c5edad
fix(hooks): coalesce common-dir cache misses
peyton-alt Sep 23, 2026
f69d444
fix(hooks): honor Lefthook local config forms
peyton-alt Sep 23, 2026
6931800
test(status): cover missing native hook paths
peyton-alt Sep 24, 2026
336b464
fix(status): stop promising a push will sync when no hook runs it
peyton-alt Sep 24, 2026
8e401b2
fix(hooks): warn when LEFTHOOK=0 skips Entire's Lefthook hooks
peyton-alt Sep 24, 2026
9bb0722
fix(hooks): stop warning that hk overwrites Entire's hooks on Git 2.54+
peyton-alt Sep 24, 2026
40f1fc1
Merge origin/main into fix/lefthook-integration
peyton-alt Oct 19, 2026
fc5d778
fix(uninstall): name the Lefthook registration in the confirmation
peyton-alt Oct 19, 2026
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: 11 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,17 +99,27 @@ jobs:

test-windows:
runs-on: windows-latest
timeout-minutes: 10
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
# Lefthook owns .git/hooks/* and runs Entire's hooks as `scripts` with a
# bash runner, which is the one part of that integration Windows can
# break. Installing the real binary is what makes
# TestLefthookDeliversEntireOnWindows a test rather than a skip;
# ENTIRE_TEST_REQUIRE_LEFTHOOK turns a failed install into a failure
# instead of a silent pass.
- name: Install lefthook
run: go install github.com/evilmartians/lefthook/v2@v2.1.10
# Real-Windows tests must include Windows or MSYS in their top-level
# test name so this job selects them without running POSIX-only harnesses.
- name: Windows unit tests
env:
ENTIRE_TEST_REQUIRE_LEFTHOOK: "1"
run: go test ./cmd/entire/cli/... -run '(Windows|MSYS)' -count=1 -v

test:
Expand Down
50 changes: 50 additions & 0 deletions cmd/entire/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,56 @@ func checkGitHooks(cmd *cobra.Command, force bool) error {
ctx := cmd.Context()
w := cmd.OutOrStdout()

// A Lefthook repo is judged on Entire's registration with Lefthook, not on
// .git/hooks/*: Lefthook owns those files and rewrites them constantly, so
// their contents say nothing about whether Entire runs. Every other
// manager only overwrites at install time, so for those the hook files
// remain the answer and the check below is the right one.
delivery := strategy.CheckHookDelivery(ctx)
if delivery.Declined != "" {
// Entire's own hooks are the arrangement here, so the checks below are
// the right ones — but say why Lefthook is not carrying it, since
// otherwise a Lefthook repo silently looks like any other.
fmt.Fprintf(w, "Note: Entire is not registered in Lefthook's config (%s,\n", delivery.Declined)
fmt.Fprintln(w, " and Entire will not modify it). Entire's own Git hooks are used instead.")
}
if delivery.Manager == strategy.LefthookManagerName {
if delivery.OK {
// --force also reconciles .git/hooks/*, which is where a repo left
// mid-fight — Entire's own hook chaining to Lefthook's displaced
// launcher, so every hook runs Entire twice — gets straightened
// out. Delivery reads OK in that state, so it needs asking for.
if force {
if _, err := strategy.EnsureLefthookIntegration(ctx); err != nil {
return fmt.Errorf("reconcile %s hooks: %w", delivery.Manager, err)
}
}
fmt.Fprintf(w, "✓ Git hooks: OK (via %s)\n", delivery.Manager)
if delivery.SkippedBy != "" {
fmt.Fprintf(w, " Warning: %s %s.\n", delivery.SkippedBy, hooksSkippedByEnvSuffix)
}
return nil
}
fmt.Fprintf(w, "Git hooks: NOT DELIVERING (%s)\n", delivery.Manager)
fmt.Fprintf(w, " %s\n", delivery.Reason)
fmt.Fprintln(w, " Fix: re-register Entire in Lefthook's configuration.")
if !force {
fmt.Fprintln(w, " Run `entire doctor --force` to apply it.")
return nil
}
if _, err := strategy.EnsureLefthookIntegration(ctx); err != nil {
return fmt.Errorf("register Entire with Lefthook: %w", err)
}
// Registering is not enough when Lefthook has no hook file for a hook
// — git runs nothing there. The native install fills exactly those
// gaps: it skips every path Lefthook already owns.
if _, err := strategy.ReinstallGitHooks(ctx); err != nil {
return fmt.Errorf("install git hooks alongside Lefthook: %w", err)
}
fmt.Fprintln(w, " ✓ Re-registered")
return nil
}

switch strategy.CheckGitHookState(ctx) {
case strategy.GitHooksCurrent:
fmt.Fprintln(w, "✓ Git hooks: OK")
Expand Down
46 changes: 34 additions & 12 deletions cmd/entire/cli/settings/opf_command_trust.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,24 @@ const (
localOwn // verified not versioned, or no repository to clone from
)

// PathIsTracked reports whether a repo-relative path is carried by the
// repository — present in the index of any clone that checks it out.
//
// Exported so callers outside this package do not hand-roll the probe: two
// commits' worth of correctness lives in it that an open-coded index scan
// does not get. `pathsEqualFold` rather than an exact compare, because on a
// case-insensitive volume a differently-cased path is the same file; a
// missing repository is a definitive "not tracked" rather than a failure;
// an error means "could not determine" and must not be read as a negative;
// and the answer is memoized, since trackedness cannot change inside one
// short-lived hook process.
//
// Index-only, matching classifyLocalSettings: the question is whether the
// repository carries the file, and HEAD costs more than it answers here.
func PathIsTracked(ctx context.Context, repoRoot, relPath string) (bool, error) {
return pathIsVersioned(ctx, repoRoot, relPath, false)
}

// classifyLocalSettings checks the git index only.
//
// The index is what a delivered attack shows up in: a pull request that
Expand Down Expand Up @@ -185,7 +203,10 @@ func classifyLocalSettingsDeep(ctx context.Context, path string) localTrust {
}

func classify(ctx context.Context, path string, deep bool) localTrust {
switch versioned, err := localSettingsIsVersioned(ctx, path, deep); {
// The settings file is always <worktree>/.entire/settings.local.json, so
// its root is two levels up. Callers that know their root pass it
// directly through PathIsTracked.
switch versioned, err := pathIsVersioned(ctx, filepath.Dir(filepath.Dir(path)), EntireSettingsLocalFile, deep); {
case err != nil:
return localUnverifiable
case versioned:
Expand All @@ -199,7 +220,8 @@ func classify(ctx context.Context, path string, deep bool) localTrust {
// subsumes the shallow one, but they are asked by different callers and only
// the rare OPF path pays for HEAD.
type probeKey struct {
path string
root string
rel string
deep bool
}

Expand Down Expand Up @@ -230,13 +252,13 @@ func ClearVersionedPathCache() {
clear(versionedPaths)
}

// localSettingsIsVersioned reports whether the local settings file is tracked
// in the index, and with deep set, whether it is also present in HEAD.
// pathIsVersioned reports whether a repo-relative path is tracked in the
// index, and with deep set, whether it is also present in HEAD.
//
// An error means "could not determine" (unreadable repository, unreadable
// index) and callers must treat it as untrusted rather than as a negative.
func localSettingsIsVersioned(ctx context.Context, path string, deep bool) (bool, error) {
key := probeKey{path: path, deep: deep}
func pathIsVersioned(ctx context.Context, repoRoot, rel string, deep bool) (bool, error) {
key := probeKey{root: repoRoot, rel: rel, deep: deep}

versionedPathsMu.Lock()
cached, ok := versionedPaths[key]
Expand All @@ -245,7 +267,7 @@ func localSettingsIsVersioned(ctx context.Context, path string, deep bool) (bool
return cached, nil
}

versioned, err := probeLocalSettingsIsVersioned(ctx, path, deep)
versioned, err := probePathIsVersioned(ctx, repoRoot, rel, deep)
if err != nil {
return false, err
}
Expand All @@ -271,12 +293,12 @@ func localSettingsIsVersioned(ctx context.Context, path string, deep bool) (bool
// the grandparent would name the wrong directory. It holds today because
// readConfined can only have succeeded on the relative form when the working
// directory IS the worktree root.
func probeLocalSettingsIsVersioned(ctx context.Context, path string, deep bool) (bool, error) {
func probePathIsVersioned(ctx context.Context, repoRoot, rel string, deep bool) (bool, error) {
if err := ctx.Err(); err != nil {
return false, fmt.Errorf("verify %s: %w", EntireSettingsLocalFile, err)
return false, fmt.Errorf("verify %s: %w", rel, err)
}

repo, err := gitrepo.OpenPath(filepath.Dir(filepath.Dir(path)))
repo, err := gitrepo.OpenPath(repoRoot)
if err != nil {
// No repository at all (no .git). A file cannot have arrived by
// cloning when there is nothing to clone, so this is a definitive
Expand All @@ -296,7 +318,7 @@ func probeLocalSettingsIsVersioned(ctx context.Context, path string, deep bool)
return false, fmt.Errorf("read index: %w", err)
}
for _, entry := range idx.Entries {
if pathsEqualFold(entry.Name, EntireSettingsLocalFile) {
if pathsEqualFold(entry.Name, rel) {
return true, nil
}
}
Expand All @@ -321,7 +343,7 @@ func probeLocalSettingsIsVersioned(ctx context.Context, path string, deep bool)
if err != nil {
return false, fmt.Errorf("read HEAD tree: %w", err)
}
return treeHasPathFold(tree, strings.Split(EntireSettingsLocalFile, "/"))
return treeHasPathFold(tree, strings.Split(rel, "/"))
}

// pathsEqualFold reports whether two repo-relative git paths can name the same
Expand Down
20 changes: 9 additions & 11 deletions cmd/entire/cli/settings/opf_command_trust_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,18 +234,18 @@ func TestPathIsVersioned_MemoizesWithinProcess(t *testing.T) {
root, _, local := newOPFRepo(t)
writeSettingsFile(t, local, localOPFSettings(attackerCommand))

first, err := localSettingsIsVersioned(t.Context(), local, true)
first, err := pathIsVersioned(t.Context(), filepath.Dir(filepath.Dir(local)), EntireSettingsLocalFile, true)
require.NoError(t, err)
require.False(t, first, "file is untracked to begin with")

testutil.RunGit(t, root, "add", "-f", EntireSettingsLocalFile)

second, err := localSettingsIsVersioned(t.Context(), local, true)
second, err := pathIsVersioned(t.Context(), filepath.Dir(filepath.Dir(local)), EntireSettingsLocalFile, true)
require.NoError(t, err)
assert.False(t, second, "result is cached for the process lifetime")

ClearVersionedPathCache()
third, err := localSettingsIsVersioned(t.Context(), local, true)
third, err := pathIsVersioned(t.Context(), filepath.Dir(filepath.Dir(local)), EntireSettingsLocalFile, true)
require.NoError(t, err)
assert.True(t, third, "the reset seam must drop the cached verdict")
}
Expand All @@ -263,7 +263,7 @@ func TestPathIsVersioned_NoGitBinaryRequired(t *testing.T) {

t.Setenv("PATH", "")

versioned, err := probeLocalSettingsIsVersioned(t.Context(), local, true)
versioned, err := probePathIsVersioned(t.Context(), filepath.Dir(filepath.Dir(local)), EntireSettingsLocalFile, true)
require.NoError(t, err, "probe must not depend on the git binary")
assert.True(t, versioned, "committed file must still be detected")
}
Expand All @@ -284,12 +284,12 @@ func TestPathIsVersioned_LinkedWorktreeUsesOwnIndex(t *testing.T) {
local := filepath.Join(wt, EntireSettingsLocalFile)
require.NoError(t, os.WriteFile(local, []byte(localOPFSettings(trustedCommand)), 0o644))

versioned, err := probeLocalSettingsIsVersioned(t.Context(), local, true)
versioned, err := probePathIsVersioned(t.Context(), filepath.Dir(filepath.Dir(local)), EntireSettingsLocalFile, true)
require.NoError(t, err)
assert.False(t, versioned, "untracked in the linked worktree")

testutil.RunGit(t, wt, "add", "-f", EntireSettingsLocalFile)
versioned, err = probeLocalSettingsIsVersioned(t.Context(), local, true)
versioned, err = probePathIsVersioned(t.Context(), filepath.Dir(filepath.Dir(local)), EntireSettingsLocalFile, true)
require.NoError(t, err)
assert.True(t, versioned, "must read the linked worktree's own index")
}
Expand All @@ -316,7 +316,7 @@ func TestPathIsVersioned_ReftableRepo(t *testing.T) {
testutil.RunGit(t, root, "commit", "-m", "carry")
testutil.RunGit(t, root, "rm", "--cached", EntireSettingsLocalFile)

versioned, err := probeLocalSettingsIsVersioned(t.Context(), local, true)
versioned, err := probePathIsVersioned(t.Context(), filepath.Dir(filepath.Dir(local)), EntireSettingsLocalFile, true)
require.NoError(t, err, "reftable repo must be verifiable")
assert.True(t, versioned, "HEAD lookup must work on reftable")
}
Expand Down Expand Up @@ -528,8 +528,7 @@ func TestPathIsVersioned_DecoySiblingDoesNotMaskRealEntry(t *testing.T) {
commitIndexAndClear(t, root, tc.decoy, EntireSettingsLocalFile)

ClearVersionedPathCache()
got, err := probeLocalSettingsIsVersioned(
t.Context(), filepath.Join(root, EntireSettingsLocalFile), true)
got, err := probePathIsVersioned(t.Context(), root, EntireSettingsLocalFile, true)
require.NoError(t, err)
assert.True(t, got, "the real entry must be found past the earlier-sorting decoy")
})
Expand All @@ -554,8 +553,7 @@ func TestPathIsVersioned_Win32TrailingCharVariantsAreTracked(t *testing.T) {
stageBlobAt(t, root, variant)

ClearVersionedPathCache()
got, err := probeLocalSettingsIsVersioned(
t.Context(), filepath.Join(root, EntireSettingsLocalFile), false)
got, err := probePathIsVersioned(t.Context(), root, EntireSettingsLocalFile, false)
require.NoError(t, err)
assert.True(t, got, "a Win32-equivalent committed name must count as tracked")
})
Expand Down
45 changes: 31 additions & 14 deletions cmd/entire/cli/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -2871,6 +2871,9 @@ func runUninstall(ctx context.Context, w, errW io.Writer, force bool) error {
// version is stale but still ours, and uninstall must still offer to remove
// it rather than reporting that Entire is not installed here.
gitHooksInstalled := strategy.AnyGitHookInstalled(ctx)
// Checked on its own: in a Lefthook repo .git/hooks/* are Lefthook's, so
// the check above misses the config files uninstall will still edit.
lefthookRegistered := strategy.LefthookIntegrationPresent(ctx)
// One sweep, threaded onwards: each external plugin costs a subprocess to ask,
// and the removal below must act on exactly what the summary showed.
agHookState := getAgentHookState(ctx)
Expand All @@ -2888,7 +2891,7 @@ func runUninstall(ctx context.Context, w, errW io.Writer, force bool) error {
// reports it with its remedy, and the run exits non-zero rather than
// asserting an absence it could not verify. The retired Gemini hooks get
// the same treatment when their config could not be read.
if !entireDirExists && !gitHooksInstalled && sessionStateCount == 0 &&
if !entireDirExists && !gitHooksInstalled && !lefthookRegistered && sessionStateCount == 0 &&
shadowBranchCount == 0 && len(agHookState.installed) == 0 &&
len(agHookState.unchecked) == 0 && !geminiHooks && geminiHooksErr == nil {
fmt.Fprintln(w, "Entire is not installed in this repository.")
Expand All @@ -2898,13 +2901,14 @@ func runUninstall(ctx context.Context, w, errW io.Writer, force bool) error {
// Show confirmation prompt unless --force
if !force {
confirmed, err := confirmUninstall(p, uninstallSummary{
entireDirExists: entireDirExists,
gitHooksInstalled: gitHooksInstalled,
sessionStateCount: sessionStateCount,
shadowBranchCount: shadowBranchCount,
hookState: agHookState,
geminiHooks: geminiHooks,
geminiHooksErr: geminiHooksErr,
entireDirExists: entireDirExists,
gitHooksInstalled: gitHooksInstalled,
lefthookRegistered: lefthookRegistered,
sessionStateCount: sessionStateCount,
shadowBranchCount: shadowBranchCount,
hookState: agHookState,
geminiHooks: geminiHooks,
geminiHooksErr: geminiHooksErr,
})
if err != nil {
return err
Expand Down Expand Up @@ -2948,8 +2952,16 @@ func uninstallGitHooks(ctx context.Context, p *uninstallPrinter) bool {
p.warnUnder("failed to remove git hooks: %v", err)
return false
}
if res.Removed > 0 {
p.step("Removed git hooks (%d)", res.Removed)
// 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")
p.warnUnder("failed to remove the Lefthook integration: %v", err)
return false
}
if removed := res.Removed + lefthookRemoved; removed > 0 {
p.step("Removed git hooks (%d)", removed)
} else {
p.noop("No git hooks to remove")
}
Expand Down Expand Up @@ -3040,9 +3052,11 @@ func uninstallShadowBranches(ctx context.Context, p *uninstallPrinter) bool {
type uninstallSummary struct {
entireDirExists bool
gitHooksInstalled bool
sessionStateCount int
shadowBranchCount int
hookState agentHookState
// lefthookRegistered: Entire's Lefthook config, scripts or extends entry.
lefthookRegistered bool
sessionStateCount int
shadowBranchCount int
hookState agentHookState
// geminiHooks reports Entire hooks left in .gemini/settings.json by removed
// Gemini CLI support; geminiHooksErr is set when that file could not be
// checked.
Expand All @@ -3057,7 +3071,7 @@ func confirmUninstall(p *uninstallPrinter, summary uninstallSummary) (bool, erro
p.blank()
p.plain("This will completely remove Entire from this repository:")
p.blank()
rows := make([]explainRow, 0, 7)
rows := make([]explainRow, 0, 8)
if len(summary.hookState.installed) > 0 {
rows = append(rows, explainRow{Label: "agent hooks", Value: strings.Join(agentDisplayNames(summary.hookState.installed), ", ")})
}
Expand All @@ -3075,6 +3089,9 @@ func confirmUninstall(p *uninstallPrinter, summary uninstallSummary) (bool, erro
if summary.gitHooksInstalled {
rows = append(rows, explainRow{Label: "git hooks", Value: "prepare-commit-msg, commit-msg, post-commit, post-rewrite, pre-push"})
}
if summary.lefthookRegistered {
rows = append(rows, explainRow{Label: "Lefthook", Value: "entire-lefthook.yml, .lefthook-local/, and Entire's extends entry in the Lefthook local config"})
}
if summary.sessionStateCount > 0 {
rows = append(rows, explainRow{Label: "session states", Value: strconv.Itoa(summary.sessionStateCount)})
}
Expand Down
Loading
Loading