blob: 8e70b8eb49845b15eb78620849f95ca20e272517 [file]
#!/bin/sh
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Installs Kiro commit attribution into a repository.
#
# Usage:
# ./install.sh install into the current repository
# ./install.sh /path/to/repo install into another repository
# ./install.sh --check report status without changing anything
# ./install.sh --uninstall remove what this script installed
#
# Where the hook goes depends on core.hooksPath, which git consults in place of
# .git/hooks whenever it is set. Two tools set it in practice and they behave
# differently, so the destination is decided by testing rather than by name:
#
# husky (repository scope, e.g. config-ui/.husky) does NOT forward to
# .git/hooks - its shim only re-executes itself. Verified by installing into
# .git/hooks in such a repo and watching the trailer never appear. So the hook
# must go into the husky directory itself.
#
# git-defender (system scope, a root-owned directory) DOES invoke
# .git/hooks/prepare-commit-msg - its own error text says "Your local
# prepare-commit-msg hook failed". Its directory is not writable anyway, so
# .git/hooks is both the only option and the correct one.
#
# The rule that covers both: install into the directory git reads, unless that
# directory is unwritable, in which case fall back to .git/hooks - which is
# exactly the case where the tooling forwards there.
#
# core.hooksPath itself is never modified. Redirecting it would be worse than
# useless under git-defender, which reports the event ("User had a non Code
# Defender hooks path value set"), tripping a security signal while solving
# nothing.
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
MODE=install
TARGET_REPO=.
MARKER='# kiro-attribution'
for arg in "$@"; do
case "$arg" in
--check) MODE=check ;;
--uninstall) MODE=uninstall ;;
--help|-h) sed -n '2,8p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
-*) echo "unknown option: $arg" >&2; exit 2 ;;
*) TARGET_REPO=$arg ;;
esac
done
cd "$TARGET_REPO" 2>/dev/null || { echo "not a directory: $TARGET_REPO" >&2; exit 1; }
# --absolute-git-dir, not --git-dir: the latter returns a path relative to the
# current directory (".git" at the repo root), and the verification step below
# cd's elsewhere. Every path derived from a relative GIT_DIR would then resolve
# against the wrong directory - silently, since sed on a missing file just
# produces an empty hook that chmod happily marks executable.
GIT_DIR=$(git rev-parse --absolute-git-dir 2>/dev/null) || {
echo "not a git repository: $TARGET_REPO" >&2; exit 1
}
REPO_ROOT=$(git rev-parse --show-toplevel)
SESSION_FILE="$GIT_DIR/kiro-sessions"
# resolve_hook_dir prints the directory to install into, plus a short reason.
resolve_hook_dir() {
_hp=$(git config --get core.hooksPath 2>/dev/null || true)
if [ -z "$_hp" ]; then
echo "$GIT_DIR/hooks|git default"
return
fi
case "$_hp" in
/*) _abs=$_hp ;;
*) _abs="$REPO_ROOT/$_hp" ;;
esac
# Writability is the deciding test. An unwritable hooksPath belongs to managed
# tooling, and that tooling is the kind that forwards to .git/hooks.
if [ -d "$_abs" ] && [ -w "$_abs" ]; then
echo "$_abs|core.hooksPath ($_hp)"
elif [ ! -d "$_abs" ]; then
echo "$_abs|core.hooksPath ($_hp), creating it"
else
echo "$GIT_DIR/hooks|core.hooksPath ($_hp) is not writable, falling back"
fi
}
RESOLVED_DIR=$(resolve_hook_dir)
HOOK_DIR=${RESOLVED_DIR%|*}
HOOK_WHY=${RESOLVED_DIR#*|}
HOOK_FILE="$HOOK_DIR/prepare-commit-msg"
# ---------------------------------------------------------------- check --------
if [ "$MODE" = check ]; then
echo "repository: $REPO_ROOT"
echo "hook dir: $HOOK_DIR"
echo " chosen by: $HOOK_WHY"
if [ -f "$HOOK_FILE" ] && grep -q "$MARKER" "$HOOK_FILE" 2>/dev/null; then
if [ -x "$HOOK_FILE" ]; then
echo "commit hook: installed"
else
echo "commit hook: installed but NOT EXECUTABLE - git will skip it"
fi
elif [ -f "$HOOK_FILE" ]; then
echo "commit hook: a different prepare-commit-msg is present (not ours)"
else
echo "commit hook: not installed"
fi
KIRO_HOOK="$REPO_ROOT/.kiro/hooks/kiro-attribution.json"
if [ -f "$KIRO_HOOK" ]; then
echo "kiro hook: registered"
else
echo "kiro hook: not registered"
fi
if [ -f "$SESSION_FILE" ]; then
echo "session log: $(wc -l < "$SESSION_FILE" | tr -d ' ') entries"
else
echo "session log: none yet (the Kiro hook has not fired in this repo)"
fi
# Coverage decides whether attribution can support a conclusion at all.
# Unknown coverage means unusable data, not partial data: a commit with no
# trailer is indistinguishable between "no AI was used" and "the hook was
# never installed on that machine".
TOTAL=$(git rev-list --count HEAD 2>/dev/null || echo 0)
TAGGED=$(git log --all --grep='^Kiro-Session-Id:' --oneline 2>/dev/null | wc -l | tr -d ' ')
echo "commits: $TOTAL total, $TAGGED with a Kiro-Session-Id trailer"
exit 0
fi
# ------------------------------------------------------------ uninstall --------
if [ "$MODE" = uninstall ]; then
if [ -f "$HOOK_FILE" ] && grep -q "$MARKER" "$HOOK_FILE" 2>/dev/null; then
if [ -f "$HOOK_FILE.kiro-backup" ]; then
mv "$HOOK_FILE.kiro-backup" "$HOOK_FILE"
echo "restored the pre-existing prepare-commit-msg"
else
rm -f "$HOOK_FILE"
echo "removed $HOOK_FILE"
fi
else
echo "no hook installed by this script"
fi
# Also clear a stale copy in .git/hooks. A repo can acquire core.hooksPath
# after the hook was installed (adding husky does exactly that), leaving an
# orphan behind that --check would no longer look at.
STALE="$GIT_DIR/hooks/prepare-commit-msg"
if [ "$STALE" != "$HOOK_FILE" ] && [ -f "$STALE" ] && grep -q "$MARKER" "$STALE" 2>/dev/null; then
rm -f "$STALE"
echo "removed a stale copy at $STALE"
fi
rm -f "$REPO_ROOT/.kiro/hooks/kiro-attribution.json" 2>/dev/null || true
echo "note: $SESSION_FILE is left in place; delete it manually if unwanted"
exit 0
fi
# -------------------------------------------------------------- install --------
mkdir -p "$HOOK_DIR"
# Chain to an unrelated hook rather than replacing it: silently dropping another
# team's hook would break their workflow with nothing to point at.
CHAIN_TO=""
if [ -f "$HOOK_FILE" ] && ! grep -q "$MARKER" "$HOOK_FILE" 2>/dev/null; then
mv "$HOOK_FILE" "$HOOK_FILE.kiro-backup"
CHAIN_TO="$HOOK_FILE.kiro-backup"
echo "kept the existing prepare-commit-msg as prepare-commit-msg.kiro-backup"
fi
{
printf '#!/bin/sh\n'
printf '%s - appends a Kiro-Session-Id trailer.\n' "$MARKER"
printf '# Installed by tools/kiro-attribution/install.sh; safe to remove.\n'
printf 'set -eu\n\n'
if [ -n "$CHAIN_TO" ]; then
printf '# Run the pre-existing hook first. Its failure must still abort the\n'
printf '# commit, hence propagating the exit code.\n'
printf 'if [ -x "%s" ]; then "%s" "$@" || exit $?; fi\n\n' "$CHAIN_TO" "$CHAIN_TO"
fi
# Inlined rather than sourced: a developer's checkout of this repository can
# move or be deleted, and a hook pointing at a missing file would break every
# commit in that repo.
sed '1d' "$SCRIPT_DIR/prepare-commit-msg.sh"
} > "$HOOK_FILE"
chmod +x "$HOOK_FILE"
echo "installed $HOOK_FILE"
echo " location chosen by: $HOOK_WHY"
[ -n "$CHAIN_TO" ] && echo " chaining to $CHAIN_TO"
# Register the Kiro-side recorder for this workspace.
#
# The schema matters: Kiro loads only .kiro/hooks/*.json files shaped as
# {"version":"v1","hooks":[...]}. The older one-object-per-file format with a
# .kiro.hook extension is silently ignored - the log says "loaded 0 standalone
# hooks" and nothing else indicates why. Field names changed too: "when.type":
# "agentStop" became "trigger": "Stop", and "then" became "action".
#
# Hooks load at session start, so a running Kiro session must be restarted before
# a newly installed hook fires.
if [ -d "$REPO_ROOT/.kiro" ]; then
mkdir -p "$REPO_ROOT/.kiro/hooks"
# Invoked via bash, and with a repo-relative path.
#
# The bash prefix is required by Kiro's permission model: shell capability
# rules match on command prefix, and a bare script path matches nothing, so the
# hook is skipped with no error logged. "bash *" is a standard allowed pattern.
#
# Relative rather than absolute so the hook survives the repo being moved or
# re-cloned; Kiro runs hooks with the workspace root as cwd.
REL_RECORDER=$(printf '%s' "$SCRIPT_DIR/kiro-session-record.sh" | sed "s|^$REPO_ROOT/||")
cat > "$REPO_ROOT/.kiro/hooks/kiro-attribution.json" <<EOF
{
"version": "v1",
"hooks": [
{
"name": "kiro-session-record",
"description": "Records the Kiro session id so the next commit carries a Kiro-Session-Id trailer. Installed by tools/kiro-attribution/install.sh.",
"trigger": "Stop",
"action": {
"type": "command",
"command": "bash $REL_RECORDER"
},
"timeout": 15,
"enabled": true
}
]
}
EOF
echo "registered the Kiro Stop hook in $REPO_ROOT/.kiro/hooks/kiro-attribution.json"
echo " RESTART Kiro for it to take effect - hooks load at session start"
else
echo "no .kiro/ directory here, so the Kiro-side hook was not registered."
echo " Create .kiro/hooks/kiro-attribution.json with trigger \"Stop\" running:"
echo " $SCRIPT_DIR/kiro-session-record.sh"
fi
# ---------------------------------------------------------------- verify -------
# Proved rather than assumed. Every failure mode in this setup is silent: a
# missing executable bit, a timezone mismatch between the recorder and the hook,
# an off-by-one at the commit boundary. All three produce "no trailer, no
# error", which is indistinguishable from "no AI was used".
echo
echo "verifying in a scratch repository..."
VERIFY_DIR=$(mktemp -d)
trap 'rm -rf "$VERIFY_DIR"' EXIT
(
cd "$VERIFY_DIR"
git init -q
git config user.email verify@example.com
git config user.name verify
mkdir -p .git/hooks
# Point core.hooksPath at this scratch repo's own directory. Repository scope
# beats system scope, so any org-wide tooling (git-defender) stays out of the
# way while our hook still runs.
#
# --no-verify would be the obvious alternative and is wrong twice over: it
# skips every hook including the one under test, and on a managed laptop it
# records a security-scan bypass.
git config core.hooksPath .git/hooks
# Drop the chain line: the pre-existing hook belongs to the real repository and
# may well refuse to run here.
sed '/kiro-backup/d' "$HOOK_FILE" > .git/hooks/prepare-commit-msg
chmod +x .git/hooks/prepare-commit-msg
echo x > x && git add . && git commit -q -m baseline
sleep 1
printf '%s\tverify-session-id\t%s\n' "$(date -u +%s)" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
>> .git/kiro-sessions
echo y > y && git add . && git commit -q -m "with session"
if ! git log -1 --format=%B | grep -q 'Kiro-Session-Id: verify-session-id'; then
echo " FAILED: no trailer written. Do not rely on this install." >&2
exit 1
fi
echo " a trailer is written when a session was recorded"
echo z > z && git add . && git commit -q -m "no new session"
if git log -1 --format=%B | grep -q 'Kiro-Session-Id'; then
echo " FAILED: a commit with no new session inherited a trailer." >&2
exit 1
fi
echo " no trailer when no new session was recorded"
)
echo
echo "done. Check status any time with:"
echo " $SCRIPT_DIR/install.sh --check"