Edge Cases for Post-Receive Hook Gate Path Resolution in no-mistakes

The no-mistakes CLI implements a layered absolute-path resolution strategy in the post-receive hook and daemon-side normalization to prevent pipeline failures caused by relative paths like "." being passed as the gate directory.

The no-mistakes project uses a bare Git repository as a "gate" that receives pushes to trigger CI pipelines. When Git invokes the post-receive hook, the daemon must receive the absolute path of the bare repo via the --gate flag; if the hook supplies a relative path, the daemon rejects the push and the pipeline aborts before starting. Robust post-receive hook gate path resolution is therefore essential for reliable operation across different Git versions and shell environments.

How Post-Receive Hook Gate Path Resolution Works

The implementation in internal/git/hook.go generates a shell script that employs a three-tier fallback strategy to guarantee an absolute path regardless of how Git invokes the hook.

Step 1: Prefer Git's Absolute Directory

The script first attempts to use Git's built-in capability to report its absolute path. On Git 2.13+ (May 2017), git rev-parse --absolute-git-dir returns an absolute path directly.

GATE_DIR=$(git rev-parse --absolute-git-dir 2>/dev/null || :)

This is the primary resolution method found in the postReceiveHookScript function within internal/git/hook.go (lines 39-45).

Step 2: Fallback to Hook File Location

If the Git command fails—common in older versions or unusual environments—the script derives the repository root from the hook's own location ($0). It extracts the hook's directory, climbs one level up to reach the bare repo root, and resolves the physical path using pwd -P.

case "$GATE_DIR" in
  /*) ;;               # already absolute

  *)
    HOOK_PATH=$0
    case "$HOOK_PATH" in
      */*) HOOK_DIR=${HOOK_PATH%/*} ;;
      *)   HOOK_DIR=. ;;
    esac
    GATE_DIR=$(cd "$HOOK_DIR/.." 2>/dev/null && (/bin/pwd -P 2>/dev/null || pwd -P) || :)
    ;;
esac

This fallback logic appears in internal/git/hook.go (lines 46-55).

Step 3: Final Absolute Path Enforcement

After the fallback, the script double-checks that GATE_DIR starts with /. If not, it normalizes again using /bin/pwd -P or plain pwd to ensure the result is absolute before proceeding.

case "$GATE_DIR" in
  /*) ;;               # already absolute

  *) GATE_DIR=$(/bin/pwd -P 2>/dev/null || pwd -P 2>/dev/null || pwd) ;;
esac

This final enforcement step is defined in internal/git/hook.go (lines 56-59).

Daemon-Side Gate Path Normalization

Even with hook-side resolution, the daemon command notify-push performs additional validation. The normalizeNotifyGatePath function in internal/cli/daemon_cmd.go (lines 104-112) guards against malformed input by rejecting empty strings, resolving relative paths with filepath.Abs, and cleaning the result with filepath.Clean.

func normalizeNotifyGatePath(gate string) (string, error) {
    if strings.TrimSpace(gate) == "" {
        return "", fmt.Errorf("gate path is required")
    }
    abs, err := filepath.Abs(gate)
    if err != nil {
        return "", fmt.Errorf("resolve gate path: %w", err)
    }
    return filepath.Clean(abs), nil
}

Critical Edge Cases Addressed

The dual-layer defense resolves several specific failure modes documented in the codebase and tested in internal/cli/daemon_cmd_test.go.

Git Invokes Hook from cwd="." (Issue #269)

The problem: When Git invokes the hook from a working directory that collapses to ., an unprotected script would forward --gate ., which the daemon treats as an invalid gate path, aborting the pipeline.

The solution: The script resolves GATE_DIR via git rev-parse or the hook's physical location, guaranteeing an absolute path regardless of the shell's current directory.

Legacy Git Versions Without --absolute-git-dir

The problem: Git versions older than 2.13 lack the --absolute-git-dir flag, leaving GATE_DIR empty after the first attempt.

The solution: The fallback mechanism using $0 and pwd -P guarantees a usable path even when the primary method fails.

Relative Hook Paths and Symlinked Directories

The problem: If $0 is relative (e.g., just post-receive) or the gate directory is symlinked, simple concatenation yields incorrect paths or symlinked paths that mismatch the daemon's lookup.

The solution: The script extracts the hook's directory (HOOK_DIR=${HOOK_PATH%/*}) and uses cd with pwd -P to resolve physical paths. The test suite in TestNormalizeNotifyGatePathResolvesLegacyDotGate uses filepath.EvalSymlinks to verify that normalized paths match the real location (see internal/cli/daemon_cmd_test.go, lines 33-70).

Poisoned PWD Environment Variable

The problem: Some shells expose the working directory via the PWD environment variable; if set to . or manipulated, naive scripts might propagate relative paths.

The solution: The fallback logic explicitly navigates using cd and pwd -P rather than trusting environment variables. The unit test explicitly sets PWD="." and confirms the result is still absolute, validating resilience against environment variable manipulation.

Implementation Code Reference

The complete post-receive hook script generated by PostReceiveHookScript in internal/git/hook.go illustrates the full resolution chain:

#!/bin/sh

# no-mistakes post-receive hook

# Resolve the bare repo dir explicitly. Git can invoke this hook from a cwd

# whose pwd collapses to "." (issue #269), which would pass "--gate ." and be

# rejected by the daemon ("invalid gate path: ."), so the pipeline never

# starts. Prefer git's own absolute dir query (Git 2.13+, May 2017), then fall

# back to the hook file's location so a poisoned PWD still cannot produce ".".

GATE_DIR=$(git rev-parse --absolute-git-dir 2>/dev/null || :)
case "$GATE_DIR" in
  /*) ;;               # already absolute

  *)
    HOOK_PATH=$0
    case "$HOOK_PATH" in
      */*) HOOK_DIR=${HOOK_PATH%/*} ;;
      *)   HOOK_DIR=. ;;
    esac
    GATE_DIR=$(cd "$HOOK_DIR/.." 2>/dev/null && (/bin/pwd -P 2>/dev/null || pwd -P) || :)
    ;;
esac
case "$GATE_DIR" in
  /*) ;;               # already absolute

  *) GATE_DIR=$(/bin/pwd -P 2>/dev/null || pwd -P 2>/dev/null || pwd) ;;
esac

# … proceed to call `no-mistakes daemon notify-push --gate "$GATE_DIR" …`

Summary

  • Absolute path requirement: The daemon rejects relative paths like ., necessitating robust resolution in the post-receive hook.
  • Layered fallback: The hook prefers git rev-parse --absolute-git-dir, falls back to deriving the path from $0, and enforces absoluteness with pwd -P.
  • Daemon validation: The normalizeNotifyGatePath function in internal/cli/daemon_cmd.go provides a secondary defense, cleaning and validating the path with filepath.Abs and filepath.Clean.
  • Symlink and environment resilience: The implementation handles symlinked gate directories and poisoned PWD variables by resolving physical paths rather than relying on environment state.
  • Test coverage: TestNormalizeNotifyGatePathResolvesLegacyDotGate explicitly validates the . cwd scenario to prevent regression of issue #269.

Frequently Asked Questions

What happens if the post-receive hook passes a relative gate path?

The daemon command notify-push rejects the push. In internal/cli/daemon_cmd.go, the normalizeNotifyGatePath function checks if the path is empty or resolves it to an absolute path; if resolution fails or the path remains relative, the daemon returns an error and the pipeline never starts.

How does no-mistakes handle older Git versions that lack --absolute-git-dir?

The hook script generated by internal/git/hook.go implements a fallback mechanism. If git rev-parse --absolute-git-dir fails (common in Git < 2.13), the script derives the repository root from the hook's own location using $0, then navigates to the parent directory and uses pwd -P to obtain an absolute path.

Why does the daemon normalize the gate path again if the hook already resolved it?

The daemon-side normalization in normalizeNotifyGuardPath serves as a defense-in-depth measure. It protects against any edge cases where the hook might fail or be circumvented, ensuring that filepath.Clean removes any . or .. segments and that filepath.Abs resolves the path relative to the daemon's actual working directory.

How are symlinked gate directories handled during path resolution?

The hook script uses cd and pwd -P (or /bin/pwd -P) to resolve physical paths without symlinks. Additionally, the test suite in internal/cli/daemon_cmd_test.go uses filepath.EvalSymlinks to verify that the normalized gate path matches the real directory location, ensuring consistency even when the gate path involves symbolic links.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →