project-root.sh 4.67 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
#!/usr/bin/env bash
#
# Deterministic project-root resolver for all humanize hooks and scripts.
#
# Resolution priority:
#   1. CLAUDE_PROJECT_DIR (set by Claude Code, stable across `cd` within a session)
#   2. git rev-parse --show-toplevel (nearest enclosing repo)
#   3. Non-zero return.
#
# pwd is intentionally NOT used as a fallback: it drifts with `cd`
# invocations during a session and silently causes state.md lookups
# under .humanize/rlcr/ to miss the active loop directory.
#
# The resolved path is passed through realpath so symlinked prefixes
# (e.g. /Users/x vs /private/Users/x on macOS, or /var vs /private/var)
# do not diverge between setup-time and hook-time resolution.
#
# Path-comparison sites in validators must mirror this by canonicalizing
# the user-provided side as well; use the companion `canonicalize_path`
# helper below.
#

if [[ -n "${_HUMANIZE_PROJECT_ROOT_SOURCED:-}" ]]; then
    return 0 2>/dev/null || true
fi
_HUMANIZE_PROJECT_ROOT_SOURCED=1

# resolve_project_root
#
# Prints the resolved project root to stdout. Returns 0 on success,
# 1 when neither CLAUDE_PROJECT_DIR nor a git toplevel is available.
#
# Callers that must have a project root should handle the failure:
#
#   PROJECT_ROOT="$(resolve_project_root)" || exit 0   # hook: allow natural stop
#   PROJECT_ROOT="$(resolve_project_root)" || {        # setup: hard error
#       echo "Error: cannot determine humanize project root" >&2
#       exit 1
#   }
#
resolve_project_root() {
    local root="${CLAUDE_PROJECT_DIR:-}"
    if [[ -z "$root" ]]; then
        root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
    fi
    if [[ -z "$root" ]]; then
        return 1
    fi

    local canonical
    canonical=$(canonicalize_path "$root")
    printf '%s\n' "${canonical:-$root}"
}

# canonicalize_path_prefix
#
# Resolves symlinks ONLY in the parent directory and reattaches the
# original basename verbatim. This is the right helper for comparing
# user-supplied filenames against an expected path inside a known
# directory: a symlink at /tmp/alias pointing at /real/loop/state.md
# MUST NOT canonicalize to /real/loop/state.md for comparison purposes,
# because `mv` operates on the link path itself. Resolving only the
# parent still lets a symlinked project prefix (e.g. /var vs /private/var
# on macOS) match a canonical expected path.
#
# If realpath on the parent fails, falls back to returning the input
# path unchanged (prefix cannot be canonicalized -> caller's comparison
# will correctly fail against a canonical expected path).
#
# Empty input prints nothing and returns 0.
#
canonicalize_path_prefix() {
    local path="$1"
    if [[ -z "$path" ]]; then
        return 0
    fi

    local parent base parent_real
    parent=$(dirname -- "$path")
    base=$(basename -- "$path")

    if parent_real=$(realpath "$parent" 2>/dev/null) && [[ -n "$parent_real" ]]; then
        printf '%s/%s\n' "${parent_real%/}" "$base"
        return 0
    fi

    if command -v python3 >/dev/null 2>&1; then
        parent_real=$(python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$parent" 2>/dev/null || true)
        if [[ -n "$parent_real" ]]; then
            printf '%s/%s\n' "${parent_real%/}" "$base"
            return 0
        fi
    fi

    printf '%s\n' "$path"
}

# canonicalize_path
#
# Prints the realpath of the input path. If the path itself does not
# exist yet (common for write validation before the file is created),
# canonicalizes the parent directory and reattaches the basename.
# If realpath is unavailable and python3 is missing, prints the input
# path verbatim.
#
# SECURITY NOTE: This helper dereferences symlinks at the leaf when
# the leaf exists. Do NOT use it to authorize a user-supplied path
# against an expected filename -- use canonicalize_path_prefix instead,
# which only resolves the parent.
#
# Empty input prints nothing and returns 0.
#
canonicalize_path() {
    local path="$1"
    if [[ -z "$path" ]]; then
        return 0
    fi

    local canonical=""

    if canonical=$(realpath "$path" 2>/dev/null) && [[ -n "$canonical" ]]; then
        printf '%s\n' "$canonical"
        return 0
    fi

    # Path does not exist: canonicalize parent, reattach basename.
    local parent base
    parent=$(dirname -- "$path")
    base=$(basename -- "$path")
    if canonical=$(realpath "$parent" 2>/dev/null) && [[ -n "$canonical" ]]; then
        printf '%s/%s\n' "${canonical%/}" "$base"
        return 0
    fi

    if command -v python3 >/dev/null 2>&1; then
        canonical=$(python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$path" 2>/dev/null || true)
        if [[ -n "$canonical" ]]; then
            printf '%s\n' "$canonical"
            return 0
        fi
    fi

    printf '%s\n' "$path"
}