feat(workspace): operator Co-authored-by trailer#159
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a dedicated top-level git plugin to own workspace-wide git lifecycle hooks (previously nested under observability), and adds a new prepare-commit-msg hook to append an operator Co-authored-by: trailer when operator env vars are present.
Changes:
- Move/centralize workspace git lifecycle hooks into a new
plugins/git/plugin and include it in the Claude CLI workspace image manifest. - Update the workspace
entrypoint.shto setcore.hooksPathto the new plugin’s hooks directory. - Add a
prepare-commit-msghook that appends an operatorCo-authored-by:trailer whenSYN_OPERATOR_NAMEandSYN_OPERATOR_EMAILare set, and update docs accordingly.
Reviewed changes
Copilot reviewed 9 out of 14 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| providers/workspaces/claude-cli/scripts/entrypoint.sh | Points core.hooksPath at the new plugins/git/hooks location. |
| providers/workspaces/claude-cli/manifest.yaml | Bakes the new git plugin into the workspace image. |
| plugins/observability/README.md | Updates docs to reflect the git hooks relocation and links to the new git plugin README. |
| plugins/git/hooks/prepare-commit-msg | New hook to append operator Co-authored-by: trailer when env vars are set. |
| plugins/git/hooks/pre-push | Emits git_push event via agentic_events before push. |
| plugins/git/hooks/post-rewrite | Emits git_rewrite event via agentic_events after rewrite operations. |
| plugins/git/hooks/post-merge | Emits git_merge event via agentic_events after merges. |
| plugins/git/hooks/post-commit | Emits git_commit event via agentic_events after commits. |
| plugins/git/hooks/post-checkout | Emits git_checkout event via agentic_events after branch checkouts. |
| plugins/git/hooks/install.py | Local/global installer updated to include the expanded hook set including prepare-commit-msg. |
| plugins/git/README.md | New documentation for the git plugin’s observability + attribution responsibilities. |
| plugins/git/.claude-plugin/plugin.json | Declares optional SYN_OPERATOR_* env vars via requires_env for orchestrator forwarding. |
Comments suppressed due to low confidence (2)
plugins/git/hooks/install.py:33
- The module docstring and constants were updated away from "observability", but the CLI help text still says
Install observability git hooks(ArgParser description). For consistency and to reduce confusion for local installs, update the CLI description to match the new scope (workspace git hooks / git plugin).
providers/workspaces/claude-cli/scripts/entrypoint.sh:120 - The log message and surrounding comment still refer to these as "Git observability hooks", but
core.hooksPathnow points at the newplugins/git/hooksdirectory which also contains non-observability behavior (prepare-commit-msgattribution). Update the echo/comment wording to reflect that this is the workspace git hooks plugin (observability + attribution), to avoid confusion when debugging container startup logs.
GIT_HOOKS_DIR="${AGENTIC_PLUGINS_DIR:-/opt/agentic/plugins}/git/hooks"
if [ -d "${GIT_HOOKS_DIR}" ]; then
git config --global core.hooksPath "${GIT_HOOKS_DIR}"
echo "[entrypoint] Git observability hooks installed from ${GIT_HOOKS_DIR}"
fi
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| #!/bin/sh | ||
| # prepare-commit-msg — append operator Co-authored-by trailer. | ||
| # Plugin marker for install.py uninstall detection: agentic_events | ||
| # | ||
| # When the workspace is run by an operator (e.g. via Syntropic137 selfhost), | ||
| # SYN_OPERATOR_NAME and SYN_OPERATOR_EMAIL identify the human who sponsored | ||
| # the workspace. This hook adds them as a co-author on every agent commit so | ||
| # the operator gets attribution on PRs the workspace opens. | ||
| # | ||
| # Behavior: | ||
| # - No-op when either env var is unset (backward compatible). | ||
| # - Skipped on merge / squash / commit-template sources to avoid polluting | ||
| # auto-generated messages. | ||
| # - Idempotent — won't append if the same trailer already exists. | ||
| # - Always exits 0 — never blocks a commit on attribution failure. | ||
|
|
||
| COMMIT_MSG_FILE="$1" | ||
| COMMIT_SOURCE="$2" | ||
|
|
||
| case "$COMMIT_SOURCE" in | ||
| merge|squash|commit) exit 0 ;; | ||
| esac | ||
|
|
||
| if [ -z "$SYN_OPERATOR_NAME" ] || [ -z "$SYN_OPERATOR_EMAIL" ]; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| TRAILER="Co-authored-by: ${SYN_OPERATOR_NAME} <${SYN_OPERATOR_EMAIL}>" | ||
|
|
||
| if grep -qF "$TRAILER" "$COMMIT_MSG_FILE" 2>/dev/null; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| printf '\n%s\n' "$TRAILER" >> "$COMMIT_MSG_FILE" | ||
| exit 0 |
| COMMIT_SOURCE="$2" | ||
|
|
||
| case "$COMMIT_SOURCE" in | ||
| merge|squash|commit) exit 0 ;; |
| TRAILER="Co-authored-by: ${SYN_OPERATOR_NAME} <${SYN_OPERATOR_EMAIL}>" | ||
|
|
- prepare-commit-msg: also skip 'template' source. Previously the case statement claimed to skip template-sourced messages but only matched merge/squash/commit. Verified: a template-sourced commit now leaves the message unchanged. - prepare-commit-msg: strip CR/LF from SYN_OPERATOR_NAME/EMAIL before building the trailer. Defense in depth — env vars are normally well-formed but a stray newline could otherwise inject an extra Co-authored-by line. Also exit cleanly when sanitization yields an empty value. - install.py: argparse description no longer says 'observability' (matches the new plugin scope: observability + attribution). - entrypoint.sh: comment + log message updated to 'Workspace git hooks' so the plugin's two responsibilities are visible at container startup. Verified against rebuilt image with direct hook invocation: - merge|squash|template|commit → no trailer - message → trailer added once - newline injection in env var → only 1 Co-authored-by line (sanitized, malformed text contained inside the name field). Test coverage suggestion (4th Copilot comment) is a follow-up: adding a pytest integration test that boots the workspace container and asserts the hook matrix is non-trivial scope.
3a512ce to
3c96e77
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Multiple plugins can contribute git hooks. We compose them into a single | ||
| # runtime directory ($GIT_HOOKS_DIR) and point core.hooksPath at it: | ||
| # - observability/hooks/git/ → event-emission (post-commit, pre-push, …) | ||
| # - git/hooks/ → other git concerns (prepare-commit-msg, …) | ||
| # | ||
| # Without this line, git hooks only fire in repos that have their own .git/hooks/ | ||
| # scripts, which is almost never the case for freshly cloned repos. | ||
| GIT_HOOKS_DIR="${AGENTIC_PLUGINS_DIR:-/opt/agentic/plugins}/observability/hooks/git" | ||
| if [ -d "${GIT_HOOKS_DIR}" ]; then | ||
| # Each plugin owns hook filenames it ships; collisions across plugins are not | ||
| # expected today. If two plugins ever ship the same hook name, the second | ||
| # symlink wins. | ||
| # | ||
| # Observability hooks emit JSONL events to stderr; the docker exec stream in | ||
| # AgenticEventStreamAdapter uses stderr=STDOUT to capture them alongside | ||
| # Claude's stdout, and WorkflowExecutionEngine stores them in TimescaleDB. | ||
| GIT_HOOKS_DIR="${HOME}/.git-hooks" | ||
| mkdir -p "${GIT_HOOKS_DIR}" | ||
|
|
||
| PLUGINS_BASE="${AGENTIC_PLUGINS_DIR:-/opt/agentic/plugins}" | ||
| for src_dir in "${PLUGINS_BASE}/observability/hooks/git" "${PLUGINS_BASE}/git/hooks"; do | ||
| [ -d "${src_dir}" ] || continue |
| # Multiple plugins can contribute git hooks. We compose them into a single | ||
| # runtime directory ($GIT_HOOKS_DIR) and point core.hooksPath at it: | ||
| # - observability/hooks/git/ → event-emission (post-commit, pre-push, …) | ||
| # - git/hooks/ → other git concerns (prepare-commit-msg, …) | ||
| # | ||
| # Without this line, git hooks only fire in repos that have their own .git/hooks/ | ||
| # scripts, which is almost never the case for freshly cloned repos. | ||
| GIT_HOOKS_DIR="${AGENTIC_PLUGINS_DIR:-/opt/agentic/plugins}/observability/hooks/git" | ||
| if [ -d "${GIT_HOOKS_DIR}" ]; then | ||
| # Each plugin owns hook filenames it ships; collisions across plugins are not | ||
| # expected today. If two plugins ever ship the same hook name, the second | ||
| # symlink wins. | ||
| # | ||
| # Observability hooks emit JSONL events to stderr; the docker exec stream in | ||
| # AgenticEventStreamAdapter uses stderr=STDOUT to capture them alongside | ||
| # Claude's stdout, and WorkflowExecutionEngine stores them in TimescaleDB. | ||
| GIT_HOOKS_DIR="${HOME}/.git-hooks" | ||
| mkdir -p "${GIT_HOOKS_DIR}" | ||
|
|
||
| PLUGINS_BASE="${AGENTIC_PLUGINS_DIR:-/opt/agentic/plugins}" | ||
| for src_dir in "${PLUGINS_BASE}/observability/hooks/git" "${PLUGINS_BASE}/git/hooks"; do | ||
| [ -d "${src_dir}" ] || continue | ||
| for src in "${src_dir}"/*; do | ||
| [ -f "${src}" ] || continue | ||
| name=$(basename "${src}") | ||
| # Skip non-hook files (installer, docs) | ||
| case "${name}" in install.py|*.md|*.txt) continue ;; esac | ||
| ln -sf "${src}" "${GIT_HOOKS_DIR}/${name}" |
| # Skip auto-generated message sources so we don't pollute them. | ||
| # merge — message produced by `git merge` | ||
| # squash — message produced by `git merge --squash` | ||
| # template — user has a commit.template configured | ||
| # commit — reusing an existing commit (e.g. `git commit --amend`, | ||
| # `git cherry-pick`). The original message already has any | ||
| # trailer it should have; idempotency below handles re-adds. | ||
| case "$COMMIT_SOURCE" in | ||
| merge|squash|template|commit) exit 0 ;; | ||
| esac |
| if [ -z "$SYN_OPERATOR_NAME" ] || [ -z "$SYN_OPERATOR_EMAIL" ]; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Strip CR/LF from operator inputs — env vars are normally well-formed but we | ||
| # don't want a stray newline in either to inject additional trailers. | ||
| NAME=$(printf '%s' "$SYN_OPERATOR_NAME" | tr -d '\r\n') | ||
| EMAIL=$(printf '%s' "$SYN_OPERATOR_EMAIL" | tr -d '\r\n') | ||
|
|
||
| if [ -z "$NAME" ] || [ -z "$EMAIL" ]; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| TRAILER="Co-authored-by: ${NAME} <${EMAIL}>" | ||
|
|
||
| if grep -qF "$TRAILER" "$COMMIT_MSG_FILE" 2>/dev/null; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| printf '\n%s\n' "$TRAILER" >> "$COMMIT_MSG_FILE" |
Workspace-shipped prepare-commit-msg hook for operator attribution.
NOT a Claude Code plugin — the hook lives with the claude-cli provider
itself, gets baked into the image, and is installed by the entrypoint.
When SYN_OPERATOR_NAME and SYN_OPERATOR_EMAIL are both set, every
workspace commit gets a 'Co-authored-by: NAME <EMAIL>' trailer so PRs
the agent opens are attributed to the operator who sponsored the
workspace. No-op when either env var is unset (backward compatible).
Layout:
providers/workspaces/claude-cli/
scripts/
entrypoint.sh
git-hooks/
prepare-commit-msg <- new
Changes:
- New scripts/git-hooks/prepare-commit-msg. Skips merge/squash/template
message sources to avoid polluting auto-generated messages. Does NOT
skip 'commit' source so amend / cherry-pick / rebase still pick up
the trailer when missing; idempotency check handles re-adds. Strips
CR/LF from operator inputs (defense in depth).
- Dockerfile: COPY scripts/git-hooks/ -> /opt/agentic/git-hooks/.
- entrypoint.sh: compose hooks from /opt/agentic/git-hooks/ AND
plugins/observability/hooks/git/ into $HOME/.git-hooks, then point
core.hooksPath there. Two contributing sources, well-documented.
Also lists SYN_OPERATOR_* in the env var header.
- scripts/build-provider.py: stage_scripts now copies the entire
scripts/ tree (was top-level files only) so subdirs like git-hooks/
reach the build context.
- New tests/integration/test_workspace_operator_attribution.py with
8 tests covering: no-env / with-env / one-env-only / idempotency /
template-skip / merge-skip / amend-still-gets-trailer / newline-
injection-sanitized.
Resolves #158.
Verification (rebuilt 2.1.126 image):
- 8/8 new operator-attribution tests pass.
- 27/27 pre-existing integration tests pass.
- Composed hooks layout in container:
$HOME/.git-hooks/
post-checkout -> plugins/observability/hooks/git/post-checkout
post-commit -> plugins/observability/hooks/git/post-commit
post-merge -> plugins/observability/hooks/git/post-merge
post-rewrite -> plugins/observability/hooks/git/post-rewrite
pre-push -> plugins/observability/hooks/git/pre-push
prepare-commit-msg -> /opt/agentic/git-hooks/prepare-commit-msg
- Observability git_commit events still emit on stderr.
dc05818 to
c041e38
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Resolves #158.
Summary
Workspace-shipped
prepare-commit-msghook for operator attribution. Not a Claude Code plugin — the hook lives with theclaude-cliprovider itself, gets baked into the image, and is installed by the entrypoint.When
SYN_OPERATOR_NAMEandSYN_OPERATOR_EMAILare both set, every workspace commit gets aCo-authored-by: NAME <EMAIL>trailer so PRs the agent opens are attributed to the operator who sponsored the workspace. No-op when either env var is unset (backward compatible).Layout
Changes
scripts/git-hooks/prepare-commit-msg(new). Skipsmerge/squash/templatesources to avoid polluting auto-generated messages. Does not skip thecommitsource —--amend, cherry-pick, and rebase still pick up the trailer when missing; idempotency handles re-adds. Strips CR/LF from operator inputs (defense in depth).Dockerfile:COPY scripts/git-hooks/ → /opt/agentic/git-hooks/.entrypoint.sh: composes hooks from/opt/agentic/git-hooks/(workspace-shipped) ANDplugins/observability/hooks/git/(event-emission) into$HOME/.git-hooks, then setscore.hooksPath. Two well-documented contributing sources.SYN_OPERATOR_*listed in the env-var header.scripts/build-provider.py:stage_scriptsnow copies the entirescripts/tree (was top-level files only) so subdirs likegit-hooks/reach the build context.Observability plugin: untouched. Its 5 git hooks stay where they are — they emit observability events; that's their job. The new hook sits beside them at runtime, not on top of them.
Tests
New file
tests/integration/test_workspace_operator_attribution.py— 8 tests, all passing against the rebuilt image:test_no_env_vars_leaves_message_untouchedtest_with_operator_env_appends_trailertest_only_name_set_is_no_optest_idempotent_when_trailer_already_presenttest_template_source_skippedtest_merge_source_skippedtest_amend_commit_does_get_trailercommitsourcetest_newline_in_env_var_does_not_inject_extra_trailerjust test-workspace→ 27/27 pre-existing + 8/8 new = 35 passing (plus one pre-existing flake ontest_firecrawl_api_call_inside_containerunrelated to this PR).Sister change in syntropic137
Platform side passes
SYN_OPERATOR_*from selfhost settings into the workspace setup phase — separate PR.Test plan
Build Workspace ImagesandQApass