use crate::cli::commands::{AgentFixProvider, HookEvent, HookTool};
pub(crate) fn build_thin_wrapper_script(
event: &HookEvent,
hook_type: &HookTool,
provider: Option<&str>,
global: bool,
provider_args: Option<&str>,
) -> String {
let provider_arg = provider
.filter(|p| !p.is_empty())
.map(|p| format!(" --provider {p}"))
.unwrap_or_default();
let provider_args_arg = provider_args
.filter(|a| !a.is_empty())
.map(|a| format!(" --provider-args '{}'", a.replace('\'', "'\\''")))
.unwrap_or_default();
let global_arg = if global { " --global" } else { "" };
format!(
"#!/bin/sh\nexec linthis hook run --event {} --type {}{}{}{} \"$@\"\n",
event.as_str(),
hook_type.as_str(),
provider_arg,
provider_args_arg,
global_arg,
)
}
pub(crate) fn shell_pre_push_worktree_setup() -> String {
"# Pre-push isolation via git worktree. The initial lint check will run\n\
# inside the worktree so uncommitted working-tree edits don't block a\n\
# push whose actual committed content is clean.\n\
_PREPUSH_CWD=\"$(pwd)\"\n\
_PREPUSH_REAL_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null)\"\n\
_PREPUSH_WT=\"\"\n\
if [ -n \"$_PREPUSH_REAL_ROOT\" ]; then\n\
\x20 _PREPUSH_STUB=$(printf '%s' \"$_PREPUSH_REAL_ROOT\" | tr '/' '-' | sed 's/^-//')\n\
\x20 _PREPUSH_WT_BASE=\"$HOME/.linthis/projects/$_PREPUSH_STUB/worktrees\"\n\
\x20 mkdir -p \"$_PREPUSH_WT_BASE\" 2>/dev/null\n\
\x20 # Prune orphans older than 60 minutes (remnants of Ctrl-C'd runs)\n\
\x20 find \"$_PREPUSH_WT_BASE\" -mindepth 1 -maxdepth 1 -type d -mmin +60 2>/dev/null | \\\n\
\x20 while IFS= read -r _old; do\n\
\x20\x20\x20 [ -z \"$_old\" ] && continue\n\
\x20\x20\x20 git worktree remove --force \"$_old\" >/dev/null 2>&1 || rm -rf \"$_old\"\n\
\x20 done\n\
\x20 git worktree prune >/dev/null 2>&1 || true\n\
\x20 _PREPUSH_WT=\"$_PREPUSH_WT_BASE/$(date +%s)-$$\"\n\
\x20 _prepush_cleanup_wt() {\n\
\x20\x20\x20 if [ -n \"$_PREPUSH_WT\" ] && [ -d \"$_PREPUSH_WT\" ]; then\n\
\x20\x20\x20\x20\x20 git worktree remove --force \"$_PREPUSH_WT\" >/dev/null 2>&1 || rm -rf \"$_PREPUSH_WT\"\n\
\x20\x20\x20 fi\n\
\x20 }\n\
\x20 trap '_prepush_cleanup_wt' EXIT HUP INT TERM\n\
\x20 if git worktree add --detach --quiet \"$_PREPUSH_WT\" HEAD >/dev/null 2>&1; then\n\
\x20\x20\x20 mkdir -p \"$_PREPUSH_WT/.linthis\"\n\
\x20\x20\x20 {\n\
\x20\x20\x20\x20\x20 echo \"# Generated by linthis pre-push hook — do not commit\"\n\
\x20\x20\x20\x20\x20 echo \"original_project_root = \\\"$_PREPUSH_REAL_ROOT\\\"\"\n\
\x20\x20\x20\x20\x20 echo \"purpose = \\\"pre-push check\\\"\"\n\
\x20\x20\x20\x20\x20 echo \"created_at = \\\"$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)\\\"\"\n\
\x20\x20\x20 } > \"$_PREPUSH_WT/.linthis/.worktree-meta\"\n\
\x20 else\n\
\x20\x20\x20 echo \"[linthis] ⚠ Couldn't create isolated worktree — checking working tree instead (uncommitted errors may falsely block push).\" >&2\n\
\x20\x20\x20 _PREPUSH_WT=\"\"\n\
\x20\x20\x20 trap - EXIT HUP INT TERM\n\
\x20 fi\n\
fi\n\
# Export a real-root-based review dir for the agent. The agent runs\n\
# INSIDE the worktree, where `git rev-parse --show-toplevel` returns\n\
# the worktree path — turning that into a slug yields a bizarre\n\
# `~/.linthis/projects/<wt-path-as-slug>/` directory that the script\n\
# (running outside the worktree) can't find later. Pre-computing the\n\
# path here from `_PREPUSH_REAL_ROOT` (or git toplevel as fallback)\n\
# keeps the agent's writes under the user's real project slug.\n\
if [ -n \"$_PREPUSH_STUB\" ]; then\n\
\x20 _LINTHIS_REVIEW_DIR=\"$HOME/.linthis/projects/$_PREPUSH_STUB/review/result\"\n\
else\n\
\x20 _FALLBACK_SLUG=$(git rev-parse --show-toplevel 2>/dev/null | tr '/' '-' | sed 's/^-//')\n\
\x20 _LINTHIS_REVIEW_DIR=\"$HOME/.linthis/projects/$_FALLBACK_SLUG/review/result\"\n\
fi\n\
mkdir -p \"$_LINTHIS_REVIEW_DIR\" 2>/dev/null\n\
export _LINTHIS_REVIEW_DIR\n"
.to_string()
}
pub(crate) fn shell_pre_push_worktree_enter() -> String {
"# Enter the worktree for the lint check (so linthis reads HEAD content)\n\
if [ -n \"$_PREPUSH_WT\" ]; then\n\
\x20 cd \"$_PREPUSH_WT\" || _PREPUSH_WT=\"\"\n\
fi\n"
.to_string()
}
pub(crate) fn shell_pre_push_worktree_leave() -> String {
"# Leave the worktree and tear it down before any commit-creating flow\n\
# (precise fixup, agent review) kicks in — those flows need to run in\n\
# the user's real project so their commits land on the user's branch.\n\
if [ -n \"$_PREPUSH_WT\" ]; then\n\
\x20 cd \"$_PREPUSH_REAL_ROOT\" >/dev/null 2>&1 || cd \"$_PREPUSH_CWD\" >/dev/null 2>&1 || true\n\
\x20 _prepush_cleanup_wt\n\
\x20 _PREPUSH_WT=\"\"\n\
\x20 trap - EXIT HUP INT TERM\n\
fi\n"
.to_string()
}
pub(crate) fn shell_pre_push_worktree_exit_no_cleanup() -> String {
"# cd back to real root for any commit-creating step (lint-fix dance)\n\
# but keep the worktree alive — agent review will re-enter it shortly\n\
# so the user's working tree stays untouched during the long review.\n\
if [ -n \"$_PREPUSH_WT\" ]; then\n\
\x20 cd \"$_PREPUSH_REAL_ROOT\" >/dev/null 2>&1 || cd \"$_PREPUSH_CWD\" >/dev/null 2>&1 || true\n\
fi\n"
.to_string()
}
pub(crate) fn shell_capture_agent_patch_in_wt() -> String {
"# Capture the agent's diff (inside the worktree) as $_AGENT_PATCH.\n\
# The worktree was created from HEAD, so `git diff HEAD` after the\n\
# agent's edits is exactly the agent's change-set — scoped implicitly\n\
# because the agent only touches files it cares about.\n\
_AGENT_PATCH=$(mktemp \"${TMPDIR:-/tmp}/linthis-agent.XXXXXX\" 2>/dev/null || mktemp)\n\
set --\n\
while IFS= read -r _F; do\n\
\x20 [ -z \"$_F\" ] && continue\n\
\x20 set -- \"$@\" \"$_F\"\n\
done <<_AGENT_PATCH_SCOPE_EOF_\n\
$_PUSHED_FILES\n\
_AGENT_PATCH_SCOPE_EOF_\n\
git diff --binary HEAD -- \"$@\" > \"$_AGENT_PATCH\" 2>/dev/null || true\n"
.to_string()
}
fn shell_apply_dirty_mode(footer_dirty_applied: &str, footer_conflict: &str) -> String {
format!(
"# Apply to working tree (NOT index). User re-stages manually.\n\
# We avoid `--3way` because it implies `--index`, which\n\
# rejects benign blob-hash mismatches (index stat drift vs.\n\
# patch's `a/` hash) with \"does not match index\".\n\
if git apply --binary --whitespace=nowarn \"$_AGENT_PATCH\" 2>\"$_APPLY_ERR\"; then\n\
\x20\x20\x20 rm -f \"$_APPLY_ERR\" 2>/dev/null\n\
{footer_dirty_applied}\
\x20\x20\x20 [ -f \"$_AGENT_PATCH\" ] && rm -f \"$_AGENT_PATCH\"\n\
\x20\x20\x20 exit 1\n\
\x20\x20\x20 else\n\
{footer_conflict}\
\x20\x20\x20 if [ -s \"$_APPLY_ERR\" ]; then\n\
\x20\x20\x20\x20\x20 echo \"[linthis] git apply error:\" >&2\n\
\x20\x20\x20\x20\x20 sed 's/^/[linthis] /' \"$_APPLY_ERR\" >&2\n\
\x20\x20\x20 fi\n\
\x20\x20\x20 rm -f \"$_APPLY_ERR\" 2>/dev/null\n\
\x20\x20\x20 echo \"[linthis] Patch kept at: $_AGENT_PATCH\" >&2\n\
\x20\x20\x20 exit 1\n\
\x20\x20\x20 fi\n"
)
}
fn shell_apply_failure_handler(footer_conflict: &str) -> String {
format!(
"# Apply failed (rare — patch was generated from same\n\
\x20\x20\x20\x20\x20 # HEAD that we just reset to). Restore stash and bail.\n\
\x20\x20\x20\x20\x20 _apply_cleanup_stash\n\
\x20\x20\x20\x20\x20 _APPLY_RESTORED=1\n\
\x20\x20\x20\x20\x20 trap - HUP INT TERM\n\
{footer_conflict}\
\x20\x20\x20\x20\x20 if [ -s \"$_APPLY_ERR\" ]; then\n\
\x20\x20\x20\x20\x20\x20\x20 echo \"[linthis] git apply error:\" >&2\n\
\x20\x20\x20\x20\x20\x20\x20 sed 's/^/[linthis] /' \"$_APPLY_ERR\" >&2\n\
\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20\x20\x20 rm -f \"$_APPLY_ERR\" 2>/dev/null\n\
\x20\x20\x20\x20\x20 echo \"[linthis] Patch kept at: $_AGENT_PATCH\" >&2\n\
\x20\x20\x20\x20\x20 exit 1\n"
)
}
fn shell_commit_and_restore(
save_diff_cached: &str,
footer_restore_conflict: &str,
footer_squash_applied: &str,
footer_fixup_applied: &str,
) -> String {
format!(
"{save_diff_cached}\
\x20\x20\x20\x20\x20 # Phase 4: commit. Squash folds into HEAD via the\n\
\x20\x20\x20\x20\x20 # `commit + reset --soft HEAD~2 + commit -C HEAD@{{2}}`\n\
\x20\x20\x20\x20\x20 # dance; fixup creates a separate commit.\n\
\x20\x20\x20\x20\x20 if [ \"$_FIX_MODE\" = \"squash\" ]; then\n\
\x20\x20\x20\x20\x20\x20\x20 git commit --no-verify -m \"fix(linthis): auto-fix review issues\" >/dev/null 2>&1\n\
\x20\x20\x20\x20\x20\x20\x20 git reset --soft HEAD~2 >/dev/null 2>&1\n\
\x20\x20\x20\x20\x20\x20\x20 git commit --no-verify -C HEAD@{{2}} >/dev/null 2>&1\n\
\x20\x20\x20\x20\x20 else\n\
\x20\x20\x20\x20\x20\x20\x20 git commit --no-verify -m \"fix(linthis): auto-fix review issues\" >/dev/null 2>&1\n\
\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20\x20\x20 # Phase 5: restore user state via 3-way merge so\n\
\x20\x20\x20\x20\x20 # their staged + unstaged work lands back on top of\n\
\x20\x20\x20\x20\x20 # the new HEAD. On overlap, conflict markers are\n\
\x20\x20\x20\x20\x20 # left in the WT and the stash is kept (recoverable\n\
\x20\x20\x20\x20\x20 # via `git stash list`).\n\
\x20\x20\x20\x20\x20 if [ -n \"$_USER_STASH\" ]; then\n\
\x20\x20\x20\x20\x20\x20\x20 if ! git stash apply --index \"$_USER_STASH\" >/dev/null 2>&1; then\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20 git stash store -m \"linthis: uncommitted state before pre-push squash\" \"$_USER_STASH\" >/dev/null 2>&1 || true\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20 _APPLY_RESTORED=1\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20 trap - HUP INT TERM\n\
{footer_restore_conflict}\
\x20\x20\x20\x20\x20\x20\x20\x20\x20 [ -f \"$_AGENT_PATCH\" ] && rm -f \"$_AGENT_PATCH\"\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20 exit 1\n\
\x20\x20\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20\x20\x20 _APPLY_RESTORED=1\n\
\x20\x20\x20\x20\x20 trap - HUP INT TERM\n\
\x20\x20\x20\x20\x20 if [ \"$_FIX_MODE\" = \"squash\" ]; then\n\
{footer_squash_applied}\
\x20\x20\x20\x20\x20 else\n\
{footer_fixup_applied}\
\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20\x20\x20 [ -f \"$_AGENT_PATCH\" ] && rm -f \"$_AGENT_PATCH\"\n\
\x20\x20\x20\x20\x20 # Write the fast-path sentinel BEFORE exiting. The next\n\
\x20\x20\x20\x20\x20 # `git push` will see a HEAD SHA that matches what we\n\
\x20\x20\x20\x20\x20 # just verified clean and skip the entire fix+review\n\
\x20\x20\x20\x20\x20 # flow (which would otherwise re-run for 5+ minutes).\n\
\x20\x20\x20\x20\x20 _SENTINEL_SHA=$(git rev-parse HEAD 2>/dev/null)\n\
\x20\x20\x20\x20\x20 if [ -n \"$_SENTINEL_SHA\" ] && [ -n \"$_PREPUSH_STUB\" ]; then\n\
\x20\x20\x20\x20\x20\x20\x20 _SENTINEL_DIR=\"$HOME/.linthis/projects/$_PREPUSH_STUB\"\n\
\x20\x20\x20\x20\x20\x20\x20 mkdir -p \"$_SENTINEL_DIR\" 2>/dev/null\n\
\x20\x20\x20\x20\x20\x20\x20 printf '%s\\n' \"$_SENTINEL_SHA\" > \"$_SENTINEL_DIR/pre-push-sentinel\" 2>/dev/null\n\
\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20\x20\x20 # MUST exit 1 here even though we just landed a clean\n\
\x20\x20\x20\x20\x20 # squash/fixup commit. Reason: git pre-push collects the\n\
\x20\x20\x20\x20\x20 # to-be-pushed SHA BEFORE invoking this hook. The squash\n\
\x20\x20\x20\x20\x20 # dance just rewrote HEAD locally, but git push will still\n\
\x20\x20\x20\x20\x20 # try to push the OLD (pre-squash) SHA. Exiting 0 here\n\
\x20\x20\x20\x20\x20 # ships the unfixed commit to remote and leaves the local\n\
\x20\x20\x20\x20\x20 # squashed HEAD diverged. Exiting 1 aborts the push so\n\
\x20\x20\x20\x20\x20 # the user re-runs `git push` — that re-collects the SHA\n\
\x20\x20\x20\x20\x20 # (now the squashed one) and ships the fixed version. The\n\
\x20\x20\x20\x20\x20 # sentinel above lets the second push fast-skip the\n\
\x20\x20\x20\x20\x20 # 5+ minute fix+review flow.\n\
\x20\x20\x20\x20\x20 exit 1\n"
)
}
fn shell_apply_squash_fixup_mode(
save_diff_cached: &str,
footer_conflict: &str,
footer_restore_conflict: &str,
footer_squash_applied: &str,
footer_fixup_applied: &str,
) -> String {
let failure_handler = shell_apply_failure_handler(footer_conflict);
let commit_restore = shell_commit_and_restore(
save_diff_cached,
footer_restore_conflict,
footer_squash_applied,
footer_fixup_applied,
);
format!(
"# squash / fixup: ISOLATE agent's patch from any\n\
\x20\x20\x20 # uncommitted user state in the user's WT/index, so the\n\
\x20\x20\x20 # auto-created commit contains ONLY agent fixes — never\n\
\x20\x20\x20 # the user's unrelated work-in-progress edits to the same\n\
\x20\x20\x20 # pushed files. Mirrors `shell_prepush_precise_flow`'s\n\
\x20\x20\x20 # snapshot/reset/apply/restore pattern, but the patch\n\
\x20\x20\x20 # source here is the agent's diff (already in $_AGENT_PATCH)\n\
\x20\x20\x20 # rather than `linthis -f`.\n\
\x20\x20\x20 # Phase 0: snapshot user's full WT + index state. Empty\n\
\x20\x20\x20 # string when there's nothing to stash (clean WT).\n\
\x20\x20\x20 _USER_STASH=$(git stash create 2>/dev/null || echo \"\")\n\
\x20\x20\x20 _APPLY_RESTORED=0\n\
\x20\x20\x20 _apply_cleanup_stash() {{\n\
\x20\x20\x20\x20\x20 if [ \"$_APPLY_RESTORED\" = \"0\" ] && [ -n \"$_USER_STASH\" ]; then\n\
\x20\x20\x20\x20\x20\x20\x20 git stash apply --index \"$_USER_STASH\" >/dev/null 2>&1 || \\\n\
\x20\x20\x20\x20\x20\x20\x20 git stash store -m \"linthis: pre-push apply recovery\" \"$_USER_STASH\" >/dev/null 2>&1 || true\n\
\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20 }}\n\
\x20\x20\x20 trap '_apply_cleanup_stash' HUP INT TERM\n\
\x20\x20\x20 # Phase 1: clear WT + index for pushed files only — leaves\n\
\x20\x20\x20 # other working-tree state untouched.\n\
\x20\x20\x20 while IFS= read -r _F; do\n\
\x20\x20\x20\x20\x20 [ -z \"$_F\" ] && continue\n\
\x20\x20\x20\x20\x20 git reset HEAD -- \"$_F\" >/dev/null 2>&1 || true\n\
\x20\x20\x20\x20\x20 git checkout HEAD -- \"$_F\" >/dev/null 2>&1 || true\n\
\x20\x20\x20 done <<_APPLY_RESET_EOF_\n\
$_PUSHED_FILES\n\
_APPLY_RESET_EOF_\n\
\x20\x20\x20 # Phase 2: apply agent patch onto the clean (HEAD) version\n\
\x20\x20\x20 # of the pushed files. No user state in the way → patch\n\
\x20\x20\x20 # applies cleanly; no overlap surface.\n\
\x20\x20\x20 if ! git apply --binary --whitespace=nowarn \"$_AGENT_PATCH\" 2>\"$_APPLY_ERR\"; then\n\
{failure_handler}\
\x20\x20\x20 fi\n\
\x20\x20\x20 rm -f \"$_APPLY_ERR\" 2>/dev/null\n\
\x20\x20\x20 # Phase 3: stage the pushed files. Index now contains ONLY\n\
\x20\x20\x20 # the agent's diff — user's pre-existing staged/unstaged\n\
\x20\x20\x20 # work was stashed in Phase 0 and is not visible here.\n\
\x20\x20\x20 while IFS= read -r _F; do\n\
\x20\x20\x20\x20\x20 [ -z \"$_F\" ] && continue\n\
\x20\x20\x20\x20\x20 git add -- \"$_F\" 2>/dev/null || true\n\
\x20\x20\x20 done <<_APPLY_STAGE_EOF_\n\
$_PUSHED_FILES\n\
_APPLY_STAGE_EOF_\n\
\x20\x20\x20 _SCOPED_STAGED=$(git diff --cached --name-only)\n\
\x20\x20\x20 if [ -n \"$_SCOPED_STAGED\" ]; then\n\
{commit_restore}\
\x20\x20\x20 else\n\
\x20\x20\x20\x20\x20 # Nothing to commit — restore stash and continue.\n\
\x20\x20\x20\x20\x20 _apply_cleanup_stash\n\
\x20\x20\x20\x20\x20 _APPLY_RESTORED=1\n\
\x20\x20\x20\x20\x20 trap - HUP INT TERM\n\
\x20\x20\x20 fi\n"
)
}
pub(crate) fn shell_apply_agent_patch_by_mode() -> String {
let save_diff_cached = shell_save_diff_patch_cached();
let footer_dirty_applied = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Applied,
hook_type: HookTypeLabel::GitWithAgent,
mode: FixCommitMode::Dirty,
event: &HookEvent::PrePush,
header: "Agent fixes left in working tree (dirty mode).",
indent: " ",
});
let footer_squash_applied = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Applied,
hook_type: HookTypeLabel::GitWithAgent,
mode: FixCommitMode::Squash,
event: &HookEvent::PrePush,
header: "Agent fixes squashed into latest commit. Review, then 'git push' again to ship the squashed version.",
indent: " ",
});
let footer_fixup_applied = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Applied,
hook_type: HookTypeLabel::GitWithAgent,
mode: FixCommitMode::Fixup,
event: &HookEvent::PrePush,
header: "Created fixup commit with agent fixes. Review, then 'git push' again to ship the fixup commit.",
indent: " ",
});
let footer_conflict = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Conflict,
hook_type: HookTypeLabel::GitWithAgent,
mode: FixCommitMode::Squash,
event: &HookEvent::PrePush,
header: "⚠ Your uncommitted changes overlap with agent fixes. Working tree has conflict markers — resolve, then 'git push' again.",
indent: " ",
});
let footer_restore_conflict = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Conflict,
hook_type: HookTypeLabel::GitWithAgent,
mode: FixCommitMode::Squash,
event: &HookEvent::PrePush,
header: "⚠ Squash succeeded, but restoring your uncommitted changes left conflict markers in the working tree. Recovery: 'git stash list' (find 'linthis: pre-push').",
indent: " ",
});
let dirty_mode_script = shell_apply_dirty_mode(&footer_dirty_applied, &footer_conflict);
let squash_fixup_script = shell_apply_squash_fixup_mode(
&save_diff_cached,
&footer_conflict,
&footer_restore_conflict,
&footer_squash_applied,
&footer_fixup_applied,
);
format!(
"# Apply agent's captured patch to the real project root per mode.\n\
# Empty patch = agent made no changes → nothing to do.\n\
if [ -s \"$_AGENT_PATCH\" ]; then\n\
\x20 _APPLY_ERR=$(mktemp \"${{TMPDIR:-/tmp}}/linthis-apply-err.XXXXXX\" 2>/dev/null || mktemp)\n\
\x20 if [ \"$_FIX_MODE\" = \"dirty\" ]; then\n\
{dirty_mode_script}\
\x20 else\n\
{squash_fixup_script}\
\x20 fi\n\
fi\n\
[ -f \"$_AGENT_PATCH\" ] && rm -f \"$_AGENT_PATCH\"\n"
)
}
pub(crate) fn build_pre_push_preamble() -> (String, &'static str) {
let preamble = "# For pre-push: save remote args, read stdin for push info\n\
_REMOTE_NAME=\"$1\"\n\
_REMOTE_URL=\"$2\"\n\
# Read push info from stdin: <local_ref> <local_sha> <remote_ref> <remote_sha>\n\
_IS_TAG=0\n\
_LOCAL_SHA=\"\"\n\
_REMOTE_SHA=\"\"\n\
while read -r _LREF _LSHA _RREF _RSHA; do\n\
\x20 # Skip tag pushes — no source code to check\n\
\x20 case \"$_LREF\" in refs/tags/*) _IS_TAG=1 ;; esac\n\
\x20 _LOCAL_SHA=\"$_LSHA\"\n\
\x20 _REMOTE_SHA=\"$_RSHA\"\n\
done\n\
if [ \"$_IS_TAG\" = \"1\" ]; then\n\
\x20 echo \"[linthis] Tag push detected — skipping pre-push check.\" >&2\n\
\x20 exit 0\n\
fi\n\
# Compute changed files between remote and local, with fallbacks\n\
# for when the remote SHA isn't locally reachable.\n\
_ZERO_SHA=\"0000000000000000000000000000000000000000\"\n\
if [ \"$_REMOTE_SHA\" = \"$_ZERO_SHA\" ]; then\n\
\x20 # New branch: diff against upstream or HEAD~1\n\
\x20 _BASE=$(git rev-parse '@{u}' 2>/dev/null || git rev-parse 'HEAD~1' 2>/dev/null || echo \"$_LOCAL_SHA\")\n\
else\n\
\x20 _BASE=\"$_REMOTE_SHA\"\n\
fi\n\
# Use `git diff --name-only` (tree delta between the two endpoints):\n\
# if the net effect of the push range is identical content for a\n\
# file (e.g. added then reverted within the push), that file's final\n\
# state already exists in the remote tree and has already been\n\
# reviewed — no need to re-lint. Only files whose final content\n\
# differs from the remote's tree are worth checking.\n\
_PUSHED_FILES=$(git diff --name-only \"$_BASE\"..\"$_LOCAL_SHA\" 2>/dev/null | grep -v '^$')\n\
# Fallback: if diff failed or is empty (e.g. remote SHA not fetched),\n\
# try upstream, then HEAD~1.\n\
if [ -z \"$_PUSHED_FILES\" ]; then\n\
\x20 _FALLBACK_BASE=$(git rev-parse '@{u}' 2>/dev/null || git rev-parse 'HEAD~1' 2>/dev/null)\n\
\x20 if [ -n \"$_FALLBACK_BASE\" ] && [ \"$_FALLBACK_BASE\" != \"$_BASE\" ]; then\n\
\x20\x20\x20 _PUSHED_FILES=$(git diff --name-only \"$_FALLBACK_BASE\"..HEAD 2>/dev/null | grep -v '^$')\n\
\x20\x20\x20 if [ -n \"$_PUSHED_FILES\" ]; then\n\
\x20\x20\x20\x20\x20 echo \"[linthis] Remote SHA not fetched locally — using $_FALLBACK_BASE as base.\" >&2\n\
\x20\x20\x20 fi\n\
\x20 fi\n\
fi\n\
# No files to push = nothing to check\n\
if [ -z \"$_PUSHED_FILES\" ]; then\n\
\x20 echo \"[linthis] No source files changed between $_BASE and local HEAD — skipping pre-push check.\" >&2\n\
\x20 exit 0\n\
fi\n\
_LINTHIS_FILE_COUNT=$(printf \"%s\\n\" \"$_PUSHED_FILES\" | grep -c '^')\n\
echo \"[linthis] Pre-push check: verifying $_LINTHIS_FILE_COUNT file(s)...\" >&2\n\
set --\n\
while IFS= read -r _F; do set -- \"$@\" -i \"$_F\"; done <<_EOF_\n\
$_PUSHED_FILES\n\
_EOF_\n\
\n";
let full = format!(
"{preamble}{worktree_setup}",
preamble = preamble,
worktree_setup = shell_pre_push_worktree_setup(),
);
(full, "\"$_REMOTE_NAME\" \"$_REMOTE_URL\"")
}
pub(crate) fn agent_fix_cmd_for_event(
provider: &AgentFixProvider,
hook_event: &HookEvent,
) -> String {
if matches!(hook_event, HookEvent::CommitMsg) {
agent_fix_headless_cmd_commit_msg(provider, None)
} else {
let prompt = agent_fix_prompt_for_event(hook_event);
agent_fix_headless_cmd(provider, &prompt, None)
}
}
fn shell_agent_invoke_block(
provider: &AgentFixProvider,
agent_cmd: &str,
error_msg: &str,
indent: &str,
) -> String {
format!(
"{i}_AGENT_RAN=0\n\
{i}_AGENT_MAX=\"${{LINTHIS_AGENT_MAX_AUTO_FIX:-100}}\"\n\
{i}_AGENT_COUNTS=$(linthis report count 2>/dev/null || echo \"0 0 0 0\")\n\
{i}_AGENT_ERR=${{_AGENT_COUNTS%% *}}\n\
{i}_AGENT_REM=${{_AGENT_COUNTS#* }}\n\
{i}_AGENT_WARN=${{_AGENT_REM%% *}}\n\
{i}_AGENT_FILES=${{_AGENT_COUNTS##* }}\n\
{i}_AGENT_TOTAL=$((_AGENT_ERR + _AGENT_WARN))\n\
{i}if [ \"$_AGENT_MAX\" != \"0\" ] && [ \"$_AGENT_TOTAL\" -gt \"$_AGENT_MAX\" ]; then\n\
{i} echo \"[linthis] ⚠ Too many issues ($_AGENT_TOTAL in $_AGENT_FILES files) — auto-fix skipped to avoid long blocking run\" >&2\n\
{i} echo \"[linthis] Fix interactively (live progress): linthis fix --ai --provider {provider_cli}\" >&2\n\
{i} echo \"[linthis] Raise the threshold: LINTHIS_AGENT_MAX_AUTO_FIX=$((_AGENT_TOTAL + 10)) git ...\" >&2\n\
{i} echo \"[linthis] Disable the cap: LINTHIS_AGENT_MAX_AUTO_FIX=0 git ...\" >&2\n\
{i}else\n\
{i} printf \"${{_LINTHIS_W}}[linthis] {error_msg}. Found $_AGENT_TOTAL issues in $_AGENT_FILES files — invoking {provider}...${{_LINTHIS_R}}\\n\"\n\
{i} printf \"${{_LINTHIS_W}}[linthis] ─── {provider} output (streaming; Ctrl-C to cancel) ───${{_LINTHIS_R}}\\n\"\n\
{i} _AGENT_START=$(date +%s)\n\
{i} # Wrap the agent pipeline so each command's stderr (claude/codebuddy progress,\n\
{i} # agent-stream diagnostics) is merged into stdout. IDE terminals colour stderr\n\
{i} # red, and none of this output signals failure.\n\
{i} {{ {agent} ; }} 2>&1\n\
{i} _AGENT_ELAPSED=$(($(date +%s) - _AGENT_START))\n\
{i} printf \"${{_LINTHIS_W}}[linthis] ─── {provider} done in ${{_AGENT_ELAPSED}}s ───${{_LINTHIS_R}}\\n\"\n\
{i} _AGENT_RAN=1\n\
{i}fi\n",
i = indent,
provider = provider,
provider_cli = provider.as_str(),
agent = agent_cmd,
error_msg = error_msg,
)
}
fn shell_agent_fix_hint(indent: &str) -> String {
format!(
"{i}printf \"${{_LINTHIS_W}}[linthis] \\033[0;32m✓${{_LINTHIS_WR}} Agent fix applied${{_LINTHIS_R}}\\n\"\n\
{i}printf \"${{_LINTHIS_W}}[linthis] View changes : \\033[0;36mgit diff --cached${{_LINTHIS_R}}\\n\"\n\
{i}printf \"${{_LINTHIS_W}}[linthis] Undo changes : \\033[0;33mlinthis backup undo${{_LINTHIS_R}}\\n\"\n",
i = indent
)
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) enum FooterOutcome {
Applied,
Clean,
Blocked,
Conflict,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) enum FixCommitMode {
Dirty,
Squash,
Fixup,
}
impl FixCommitMode {
#[allow(dead_code)]
pub(crate) fn as_str(self) -> &'static str {
match self {
FixCommitMode::Dirty => "dirty",
FixCommitMode::Squash => "squash",
FixCommitMode::Fixup => "fixup",
}
}
fn padded_name(self) -> &'static str {
match self {
FixCommitMode::Dirty => "dirty (d)",
FixCommitMode::Squash => "squash (s)",
FixCommitMode::Fixup => "fixup (f)",
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) enum HookTypeLabel {
Git,
GitWithAgent,
}
pub(crate) struct FooterCtx<'a> {
pub outcome: FooterOutcome,
pub hook_type: HookTypeLabel,
pub mode: FixCommitMode,
pub event: &'a HookEvent,
pub header: &'a str,
pub indent: &'a str,
}
fn footer_event_config_key(event: &HookEvent) -> &'static str {
match event {
HookEvent::PreCommit | HookEvent::PostCommit => "pre_commit",
HookEvent::PrePush => "pre_push",
HookEvent::CommitMsg => "commit_msg",
}
}
fn footer_view_cmd(event: &HookEvent, mode: FixCommitMode) -> &'static str {
match (event, mode) {
(HookEvent::PreCommit, FixCommitMode::Dirty) => "git diff",
(HookEvent::PreCommit, FixCommitMode::Squash) => "git diff --cached",
(HookEvent::PreCommit, FixCommitMode::Fixup) => "git diff HEAD~1",
(HookEvent::PrePush, FixCommitMode::Dirty) => "git diff",
(HookEvent::PrePush, FixCommitMode::Squash) => "git diff HEAD~1",
(HookEvent::PrePush, FixCommitMode::Fixup) => "git diff HEAD~1",
(HookEvent::PostCommit, _) => "git diff HEAD~1",
(HookEvent::CommitMsg, _) => "git log -1",
}
}
fn footer_undo_cmd(event: &HookEvent, mode: FixCommitMode) -> &'static str {
match (event, mode) {
(HookEvent::PreCommit, FixCommitMode::Dirty) => "linthis backup undo",
(HookEvent::PreCommit, FixCommitMode::Squash) => "git reset HEAD",
(HookEvent::PreCommit, FixCommitMode::Fixup) => "git reset HEAD~1",
(HookEvent::PrePush, FixCommitMode::Dirty) => "linthis backup undo",
(HookEvent::PrePush, FixCommitMode::Squash) => "git reset HEAD~1",
(HookEvent::PrePush, FixCommitMode::Fixup) => "git reset HEAD~1",
(HookEvent::PostCommit, _) => "git reset HEAD~1",
(HookEvent::CommitMsg, _) => "git commit --amend",
}
}
fn footer_end_hint(
outcome: FooterOutcome,
mode: FixCommitMode,
event: &HookEvent,
) -> Option<String> {
if matches!(event, HookEvent::CommitMsg | HookEvent::PostCommit) {
return None;
}
let retry_cmd = match event {
HookEvent::PrePush => "git push",
_ => "git commit",
};
match outcome {
FooterOutcome::Clean => None,
FooterOutcome::Applied => match mode {
FixCommitMode::Dirty => Some(format!(
"✓ Done — review with 'git diff', stage with 'git add', then '{retry_cmd}' again."
)),
FixCommitMode::Squash | FixCommitMode::Fixup => Some(format!(
"✓ Done — review the auto-fix, then '{retry_cmd}' again."
)),
},
FooterOutcome::Blocked => Some(format!(
"✗ Blocked — fix the errors above manually, then '{retry_cmd}' again."
)),
FooterOutcome::Conflict => Some(format!(
"⚠ Conflict — resolve markers in the working tree, then '{retry_cmd}' again."
)),
}
}
fn footer_mode_desc(event: &HookEvent, mode: FixCommitMode) -> &'static str {
match (event, mode) {
(HookEvent::PreCommit | HookEvent::PostCommit, FixCommitMode::Dirty) => {
"format left unstaged; commit blocks, you re-stage manually"
}
(HookEvent::PreCommit | HookEvent::PostCommit, FixCommitMode::Squash) => {
"format auto-staged into your commit — one commit"
}
(HookEvent::PreCommit | HookEvent::PostCommit, FixCommitMode::Fixup) => {
"commit proceeds; a separate auto-fix commit is created post-commit"
}
(HookEvent::PrePush, FixCommitMode::Dirty) => {
"fixes left in working tree; push blocks, you re-stage manually"
}
(HookEvent::PrePush, FixCommitMode::Squash) => {
"fixes squashed into latest commit; run 'git push' again"
}
(HookEvent::PrePush, FixCommitMode::Fixup) => {
"fixes go into a separate fixup commit; run 'git push' again"
}
(HookEvent::CommitMsg, _) => "commit-msg has no fix_commit_mode",
}
}
pub(crate) fn shell_hook_footer(ctx: &FooterCtx<'_>) -> String {
let i = ctx.indent;
let mut out = String::new();
out.push_str(&format!(
"{i}printf \"${{_LINTHIS_W}}[linthis] {header}${{_LINTHIS_R}}\\n\"\n",
header = ctx.header,
));
let applied = matches!(ctx.outcome, FooterOutcome::Applied);
let conflict = matches!(ctx.outcome, FooterOutcome::Conflict);
let blocked = matches!(ctx.outcome, FooterOutcome::Blocked);
let clean = matches!(ctx.outcome, FooterOutcome::Clean);
if applied || conflict {
out.push_str(&format!(
"{i}printf \"${{_LINTHIS_W}}[linthis] View changes : \\033[0;36m{view}${{_LINTHIS_R}}\\n\"\n",
view = footer_view_cmd(ctx.event, ctx.mode),
));
}
if applied {
out.push_str(&format!(
"{i}printf \"${{_LINTHIS_W}}[linthis] Undo changes : \\033[0;33m{undo}${{_LINTHIS_R}}\\n\"\n",
undo = footer_undo_cmd(ctx.event, ctx.mode),
));
out.push_str(&format!(
"{i}[ -n \"$_DIFF_FILE\" ] && printf \"${{_LINTHIS_W}}[linthis] Undo (by patch created) : \\033[0;33mgit apply -R $_DIFF_FILE${{_LINTHIS_R}}\\n\"\n"
));
}
if blocked && matches!(ctx.hook_type, HookTypeLabel::Git) {
let event_name = ctx.event.hook_filename();
out.push_str(&format!(
"{i}printf \"${{_LINTHIS_W}}[linthis] Currently: --type git${{_LINTHIS_R}}\\n\"\n\
{i}printf \"${{_LINTHIS_W}}[linthis] → Want auto-fix + AI code review? Switch hook type:${{_LINTHIS_R}}\\n\"\n\
{i}printf \"${{_LINTHIS_W}}[linthis] linthis hook install -g --type git-with-agent --provider <claude|codebuddy|...> --event {event_name} --force${{_LINTHIS_R}}\\n\"\n"
));
}
if !clean && !matches!(ctx.event, HookEvent::CommitMsg) {
let key = footer_event_config_key(ctx.event);
out.push_str(&format!(
"{i}printf \"${{_LINTHIS_W}}[linthis] → Switch fix_commit_mode: \\033[0;36mlinthis config set hook.{key}.fix_commit_mode <mode> -g${{_LINTHIS_R}}\\n\"\n"
));
for m in [
FixCommitMode::Dirty,
FixCommitMode::Squash,
FixCommitMode::Fixup,
] {
let padded = m.padded_name();
let desc = footer_mode_desc(ctx.event, m);
let current = if m == ctx.mode { " (current)" } else { "" };
out.push_str(&format!(
"{i}printf \"${{_LINTHIS_W}}[linthis] {padded} = {desc}{current}${{_LINTHIS_R}}\\n\"\n"
));
}
}
if let Some(hint) = footer_end_hint(ctx.outcome, ctx.mode, ctx.event) {
out.push_str(&format!(
"{i}printf \"${{_LINTHIS_W}}[linthis] {hint}${{_LINTHIS_R}}\\n\"\n"
));
}
out
}
fn shell_write_pending_fixup_sentinel(indent: &str) -> String {
format!(
"{i}# Write post-commit fixup sentinel. The post-commit agent hook\n\
{i}# gates its activation on the presence of this file.\n\
{i}_GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)\n\
{i}if [ -n \"$_GIT_DIR\" ]; then\n\
{i} mkdir -p \"$_GIT_DIR/linthis\" 2>/dev/null\n\
{i} {{\n\
{i} echo '{{'\n\
{i} echo ' \"event\": \"pre_commit\",'\n\
{i} echo ' \"mode\": \"fixup\",'\n\
{i} printf ' \"created_at\": \"%s\"\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)\"\n\
{i} echo '}}'\n\
{i} }} > \"$_GIT_DIR/linthis/pending-fixup.json\" 2>/dev/null\n\
{i}fi\n",
i = indent,
)
}
fn shell_save_diff_patch_for_post_commit(indent: &str) -> String {
let keep = load_retention_diffs();
format!(
"{i}# Save staged changes as a patch before fixup commit\n\
{i}_SLUG=$(git rev-parse --show-toplevel 2>/dev/null | tr '/' '-' | sed 's/^-//')\n\
{i}_DIFF_DIR=\"$HOME/.linthis/projects/$_SLUG/diff\"\n\
{i}mkdir -p \"$_DIFF_DIR\"\n\
{i}_DIFF_FILE=\"$_DIFF_DIR/diff-$(date +%Y%m%d-%H%M%S).patch\"\n\
{i}git diff --cached > \"$_DIFF_FILE\" 2>/dev/null\n\
{i}if [ -s \"$_DIFF_FILE\" ]; then\n\
{i} ls -t \"$_DIFF_DIR\"/diff-*.patch 2>/dev/null | tail -n +{keep_plus_one} | xargs rm -f 2>/dev/null\n\
{i}else\n\
{i} rm -f \"$_DIFF_FILE\"\n\
{i} _DIFF_FILE=\"\"\n\
{i}fi\n",
i = indent,
keep_plus_one = keep + 1,
)
}
pub(crate) fn build_agent_fix_block(provider: &AgentFixProvider, hook_event: &HookEvent) -> String {
let agent_cmd = agent_fix_cmd_for_event(provider, hook_event);
let agent_check = shell_agent_availability_check(provider);
let error_msg = agent_fix_error_msg(hook_event);
let new_msg_print = if matches!(hook_event, HookEvent::CommitMsg) {
agent_fix_show_fixed_cmsg(" ")
} else {
String::new()
};
let agent_block = shell_agent_invoke_block(provider, &agent_cmd, error_msg, " ");
format!(
" if [ $LINTHIS_EXIT -ne 0 ]; then\n\
\x20\x20\x20 {agent_check}\
\x20\x20\x20 if [ \"$_LINTHIS_AGENT_OK\" = \"1\" ]; then\n\
{agent_block}\
\x20\x20\x20\x20\x20 if [ \"$_AGENT_RAN\" = \"1\" ]; then\n\
{agent_hint}\
\x20\x20\x20\x20\x20\x20\x20 printf \"${{_LINTHIS_W}}[linthis] Re-verifying...${{_LINTHIS_R}}\\n\"\n\
\x20\x20\x20\x20\x20\x20\x20 _linthis_run_painted $LINTHIS_CMD \"$@\"\n\
\x20\x20\x20\x20\x20\x20\x20 LINTHIS_EXIT=$?\n\
\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20 fi\n\
{new_msg_print}\
\x20 fi\n",
agent_block = agent_block,
agent_check = agent_check,
agent_hint = shell_agent_fix_hint(" "),
new_msg_print = new_msg_print,
)
}
pub(crate) fn build_linthis_cmd_var(hook_event: &HookEvent, args: &Option<String>) -> String {
let cmd = build_hook_command(hook_event, args);
match hook_event {
HookEvent::CommitMsg => cmd.trim_end_matches(" \"$1\"").to_string(),
_ => cmd,
}
}
pub(crate) fn resolve_event_preamble(hook_event: &HookEvent) -> (String, &'static str) {
if matches!(hook_event, HookEvent::PrePush) {
build_pre_push_preamble()
} else {
(String::new(), "\"$@\"")
}
}
pub(crate) fn resolve_global_hook_blocks(
hook_event: &HookEvent,
fix_provider: Option<&AgentFixProvider>,
) -> (String, String, &'static str, &'static str) {
let fix_block = fix_provider
.map(|p| build_agent_fix_block(p, hook_event))
.unwrap_or_default();
let fix_block_direct = fix_provider
.map(|p| build_agent_fix_block(p, hook_event))
.unwrap_or_default();
let review_block = if matches!(hook_event, HookEvent::PrePush) {
"\n# Trigger background AI code review (non-blocking)\n\
linthis review --background 2>/dev/null &\n"
} else {
""
};
let timer_block = shell_timer_functions();
(fix_block, fix_block_direct, review_block, timer_block)
}
pub(crate) fn build_global_hook_script_for_event(
hook_event: &HookEvent,
args: &Option<String>,
fix_provider: Option<&AgentFixProvider>,
) -> String {
let linthis_cmd_var = build_linthis_cmd_var(hook_event, args);
let (pre_push_preamble, local_hook_orig_args) = resolve_event_preamble(hook_event);
let (fix_block, fix_block_direct, review_block, timer_block) =
resolve_global_hook_blocks(hook_event, fix_provider);
let event_name = hook_event.hook_filename();
let fix_commit_mode_section = if matches!(hook_event, HookEvent::PreCommit) {
shell_read_fix_commit_mode("pre_commit")
} else if matches!(hook_event, HookEvent::PrePush) {
shell_read_fix_commit_mode("pre_push")
} else {
String::new()
};
let git_fix_commit_mode_handler = shell_git_fix_commit_mode_handler(hook_event);
let (wt_enter, wt_leave) = if matches!(hook_event, HookEvent::PrePush) {
(
shell_pre_push_worktree_enter(),
shell_pre_push_worktree_leave(),
)
} else {
(String::new(), String::new())
};
format!(
"#!/bin/sh\n\
# linthis-hook\n\
{timer}\
LINTHIS_CMD=\"{linthis}\"\n\
{fix_commit_mode}\
# Snapshot pre-format state for stash (squash mode)\n\
if [ \"$_FIX_MODE\" = \"squash\" ]; then\n\
\x20 _STASH_REF=$(git stash create 2>/dev/null)\n\
fi\n\
{pre_push_preamble}\
# Locate the local project hook (git-dir aware)\n\
GIT_DIR=\"$(git rev-parse --git-dir 2>/dev/null)\"\n\
LOCAL_HOOK=\"\"\n\
if [ -n \"$GIT_DIR\" ]; then\n\
\x20 LOCAL_HOOK=\"$GIT_DIR/hooks/{event}\"\n\
fi\n\
\n\
if [ -f \"$LOCAL_HOOK\" ] && [ -x \"$LOCAL_HOOK\" ]; then\n\
\x20 if grep -qE '^[^#]*linthis' \"$LOCAL_HOOK\" 2>/dev/null; then\n\
\x20\x20\x20 # Local hook already calls linthis — delegate entirely\n\
\x20\x20\x20 exec \"$LOCAL_HOOK\" {local_hook_orig_args}\n\
\x20 else\n\
\x20\x20\x20 # Local hook exists but has no linthis — run linthis first, then delegate.\n\
\x20\x20\x20 # _linthis_run_painted folds stderr into stdout and pipes through the\n\
\x20\x20\x20 # VCS-console white filter while preserving linthis's exit status.\n\
{wt_enter}\
\x20\x20\x20 _linthis_run_painted $LINTHIS_CMD \"$@\"\n\
\x20\x20\x20 LINTHIS_EXIT=$?\n\
{wt_leave}\
{git_fix_handler}\
{fix_local}\
\x20\x20\x20 \"$LOCAL_HOOK\" {local_hook_orig_args}\n\
\x20\x20\x20 LOCAL_EXIT=$?\n\
{review}\
\x20\x20\x20 [ $LINTHIS_EXIT -ne 0 ] && exit $LINTHIS_EXIT\n\
\x20\x20\x20 exit $LOCAL_EXIT\n\
\x20 fi\n\
else\n\
\x20 # No local hook — run linthis directly.\n\
{wt_enter}\
\x20 _linthis_run_painted $LINTHIS_CMD \"$@\"\n\
\x20 LINTHIS_EXIT=$?\n\
{wt_leave}\
{git_fix_handler}\
{fix_direct}\
{review}\
\x20 exit $LINTHIS_EXIT\n\
fi\n",
timer = timer_block,
linthis = linthis_cmd_var,
fix_commit_mode = fix_commit_mode_section,
pre_push_preamble = pre_push_preamble,
event = event_name,
local_hook_orig_args = local_hook_orig_args,
wt_enter = wt_enter,
wt_leave = wt_leave,
git_fix_handler = git_fix_commit_mode_handler,
fix_local = fix_block,
fix_direct = fix_block_direct,
review = review_block,
)
}
pub(crate) fn agent_fix_bin(provider: &AgentFixProvider) -> std::borrow::Cow<'static, str> {
match provider {
AgentFixProvider::Claude => "claude".into(),
AgentFixProvider::Codex => "codex".into(),
AgentFixProvider::Gemini => "gemini".into(),
AgentFixProvider::Cursor => "cursor-agent".into(),
AgentFixProvider::Droid => "droid".into(),
AgentFixProvider::Auggie => "auggie".into(),
AgentFixProvider::Codebuddy => "codebuddy".into(),
AgentFixProvider::Openclaw => "openclaw".into(),
AgentFixProvider::Custom { bin, .. } => bin.clone().into(),
}
}
pub(crate) fn agent_fix_headless_cmd(
provider: &AgentFixProvider,
prompt: &str,
provider_args: Option<&str>,
) -> String {
let escaped = prompt.replace('\'', "'\\''");
let extra = provider_args
.filter(|a| !a.is_empty())
.map(|a| format!(" {a}"))
.unwrap_or_default();
match provider {
AgentFixProvider::Claude => format!(
"claude -p{extra} --verbose --output-format stream-json --dangerously-skip-permissions '{}' | linthis agent-stream",
escaped
),
AgentFixProvider::Codex => {
format!("codex exec{extra} --ask-for-approval never '{}'", escaped)
}
AgentFixProvider::Gemini => {
format!("gemini -p{extra} --approval-mode=auto_edit '{}'", escaped)
}
AgentFixProvider::Cursor => format!("cursor-agent chat{extra} --force '{}'", escaped),
AgentFixProvider::Droid => format!("droid exec{extra} --auto high '{}'", escaped),
AgentFixProvider::Auggie => format!("auggie{extra} --print '{}'", escaped),
AgentFixProvider::Codebuddy => format!(
"codebuddy -p{extra} --verbose --output-format stream-json --dangerously-skip-permissions '{}' | linthis agent-stream",
escaped
),
AgentFixProvider::Openclaw => format!("openclaw agent{extra} --message '{}'", escaped),
AgentFixProvider::Custom { bin, style } => match style.as_str() {
"claude" | "claude-cli" | "codebuddy" | "codebuddy-cli" => format!(
"{bin} -p{extra} --verbose --output-format stream-json --dangerously-skip-permissions '{}' | linthis agent-stream",
escaped
),
"codex" | "codex-cli" => format!("{bin} exec{extra} --ask-for-approval never '{}'", escaped),
"gemini" | "gemini-cli" => format!("{bin} -p{extra} --approval-mode=auto_edit '{}'", escaped),
"cursor" => format!("{bin} chat{extra} --force '{}'", escaped),
"droid" => format!("{bin} exec{extra} --auto high '{}'", escaped),
"auggie" => format!("{bin}{extra} --print '{}'", escaped),
"openclaw" => format!("{bin} agent{extra} --message '{}'", escaped),
_ => format!("{bin}{extra} '{}'", escaped),
},
}
}
pub(crate) fn shell_agent_availability_check(provider: &AgentFixProvider) -> String {
let bin = agent_fix_bin(provider);
format!(
"if command -v {bin} >/dev/null 2>&1; then\n\
\x20 _LINTHIS_AGENT_OK=1\n\
else\n\
\x20 _LINTHIS_AGENT_OK=0\n\
\x20 echo \"[linthis] ⚠ '{bin}' not found in PATH — skipping AI auto-fix\" >&2\n\
\x20 echo \"[linthis] To install: https://docs.anthropic.com/en/docs/claude-code\" >&2\n\
\x20 echo \"[linthis] To change provider: linthis hook install -g --type git-with-agent --provider <name> --event <event> --force\" >&2\n\
\x20 echo \"[linthis] Please fix the issues manually and retry.\" >&2\n\
fi\n",
bin = bin,
)
}
fn agent_fix_prompt_for_post_commit() -> String {
"Lint issues remain in committed files after auto-formatting. A backup has been created. \
Follow these steps: \
(1) Run 'linthis report show' to inspect all remaining issues. \
(2) Group the issues by file. For files with independent errors (no cross-file dependencies), \
fix them in parallel using concurrent tool calls — each tool call fixes one file. \
For files with cross-file dependencies (e.g. shared type renames, API signature changes), \
fix them sequentially in dependency order. \
Fix by editing the code directly (do NOT use linthis --fix). \
(3) Re-run 'linthis report show' and verify remaining issues. \
If none remain, you are done. Otherwise keep fixing. \
(4) Display a Changes Summary showing each modified file, \
what was changed, and why (e.g. which lint rule). Then show the full diff output."
.to_string()
}
pub(crate) fn agent_fix_prompt_for_event(hook_event: &HookEvent) -> String {
if matches!(hook_event, HookEvent::PrePush) {
return "Lint issues were found in the commits about to be pushed (HEAD content). \
You are running inside a temporary git worktree of HEAD — there are NO staged \
files; do NOT run `linthis -s`. A backup has been created. \
CRITICAL: DO NOT run `git commit`, `git add`, or any git command that mutates \
history or the index. Leave your fixes as UNCOMMITTED edits in the working \
tree — the linthis hook will capture your diff with `git diff HEAD` and replay \
it onto the user's real project per their fix_commit_mode (squash / fixup / \
dirty). If you commit, the hook sees an empty diff and your fixes never reach \
the user. \
Follow these steps: \
(1) Run `linthis report show` to see the latest lint result with file paths, \
line numbers, and rule names. \
(2) Group the issues by file. For files with independent errors (no cross-file \
dependencies), fix them in parallel using concurrent tool calls — each tool call \
fixes one file. For files with cross-file dependencies (e.g. shared type renames, \
API signature changes), fix them sequentially in dependency order. \
Fix by editing the code directly (do NOT use linthis --fix). \
(3) Re-check by running `linthis -i <file>` for each modified file (use one \
`-i` flag per file when checking multiple). Exit code 0 = all clean. \
Non-zero = issues remain — keep fixing until exit code is 0. \
(4) Run the project build/test to ensure fixes don't break anything \
(detect project type: cargo check && cargo test for Rust, \
go build ./... && go test ./... for Go, \
npx tsc --noEmit for TypeScript, python -m py_compile for Python). \
If build/tests fail, revert the problematic fix and try again. \
(5) Display a Changes Summary showing each modified file, \
what was changed, and why (e.g. which lint rule). Then show the full diff \
output (read-only — no commit)."
.to_string();
}
"Lint issues were found in staged files. A backup has been created. \
Follow these steps: \
(1) Run 'linthis -s' to inspect all issues. \
(2) Group the issues by file. For files with independent errors (no cross-file dependencies), \
fix them in parallel using concurrent tool calls — each tool call fixes one file. \
For files with cross-file dependencies (e.g. shared type renames, API signature changes), \
fix them sequentially in dependency order. \
Fix by editing the code directly (do NOT use linthis --fix). \
(3) Re-run 'linthis -s' and check the exit code. \
Exit code 0 means all checks passed — you are done. \
Non-zero means issues remain — keep fixing until exit code is 0. \
(4) Run the project build/test to ensure fixes don't break anything \
(detect project type: cargo check && cargo test for Rust, \
go build ./... && go test ./... for Go, \
npx tsc --noEmit for TypeScript, python -m py_compile for Python). \
If build/tests fail, revert the problematic fix and try again. \
(5) Display a Changes Summary showing each modified file, \
what was changed, and why (e.g. which lint rule). Then show the full diff output."
.to_string()
}
pub(crate) fn agent_fix_show_fixed_cmsg(indent: &str) -> String {
format!(
"{i}if [ $LINTHIS_EXIT -eq 0 ] && [ -n \"$_REAL_MSG\" ] && [ -f \"$_REAL_MSG\" ]; then\n\
{i} printf '\\033[0;32m[linthis] ✓ New message: %s\\033[0m\\n' \"$(cat \"$_REAL_MSG\")\" >&2\n\
{i}fi\n",
i = indent,
)
}
pub(crate) fn agent_fix_headless_cmd_commit_msg(
provider: &AgentFixProvider,
provider_args: Option<&str>,
) -> String {
let prompt = "Commit message validation failed (not in Conventional Commits format). \
Fix the commit message in $_MSG_FILE (this is a TEMP FILE outside .git/ — \
do NOT try to write to .git/COMMIT_EDITMSG, that path is blocked; \
write to $_MSG_FILE only): \
(1) read $_MSG_FILE for the current message, \
(2) run 'git diff --cached --stat' to understand what actually changed, \
(3) run 'git log -n 5 --oneline' to check recent commit style AND the language used \
(Chinese or English) — match that language for the description, \
(4) choose the correct type (feat/fix/refactor/perf/docs/style/test/build/ci/chore/revert) \
based on the diff, \
(5) rewrite to: type(scope)?: description — lowercase type, ≤72 chars, no trailing period. \
Overwrite $_MSG_FILE in place. \
Verify with 'linthis cmsg $_MSG_FILE' until it passes.";
let escaped = prompt.replace('\\', "\\\\").replace('"', "\\\"");
let bin_cmd = build_commit_msg_agent_bin_cmd(provider, &escaped, provider_args);
format!(
"_REAL_MSG=\"$1\"; \
_MSG_FILE=$(mktemp \"${{TMPDIR:-/tmp}}/linthis-cmsg.XXXXXX\" 2>/dev/null || echo \"/tmp/linthis-cmsg.$$\"); \
cp \"$_REAL_MSG\" \"$_MSG_FILE\" 2>/dev/null; \
{bin_cmd}; \
if [ -s \"$_MSG_FILE\" ] && ! cmp -s \"$_REAL_MSG\" \"$_MSG_FILE\" 2>/dev/null; then \
cp \"$_MSG_FILE\" \"$_REAL_MSG\"; \
fi; \
rm -f \"$_MSG_FILE\"",
bin_cmd = bin_cmd
)
}
fn build_commit_msg_agent_bin_cmd(
provider: &AgentFixProvider,
escaped: &str,
provider_args: Option<&str>,
) -> String {
let extra = provider_args
.filter(|a| !a.is_empty())
.map(|a| format!(" {a}"))
.unwrap_or_default();
match provider {
AgentFixProvider::Claude => format!(
"claude -p{extra} --verbose --output-format stream-json --dangerously-skip-permissions \"{}\" | linthis agent-stream",
escaped
),
AgentFixProvider::Codex => {
format!("codex exec{extra} --ask-for-approval never \"{}\"", escaped)
}
AgentFixProvider::Gemini => {
format!("gemini -p{extra} --approval-mode=auto_edit \"{}\"", escaped)
}
AgentFixProvider::Cursor => format!("cursor-agent chat{extra} --force \"{}\"", escaped),
AgentFixProvider::Droid => format!("droid exec{extra} --auto high \"{}\"", escaped),
AgentFixProvider::Auggie => format!("auggie{extra} --print \"{}\"", escaped),
AgentFixProvider::Codebuddy => format!(
"codebuddy -p{extra} --verbose --output-format stream-json --dangerously-skip-permissions \"{}\" | linthis agent-stream",
escaped
),
AgentFixProvider::Openclaw => format!("openclaw agent{extra} --message \"{}\"", escaped),
AgentFixProvider::Custom { bin, style } => {
build_custom_commit_msg_cmd(bin, style, &extra, escaped)
}
}
}
fn build_custom_commit_msg_cmd(bin: &str, style: &str, extra: &str, escaped: &str) -> String {
match style {
"claude" | "claude-cli" | "codebuddy" | "codebuddy-cli" => format!(
"{bin} -p{extra} --verbose --output-format stream-json --dangerously-skip-permissions \"{}\" | linthis agent-stream",
escaped
),
"codex" | "codex-cli" => format!("{bin} exec{extra} --ask-for-approval never \"{}\"", escaped),
"gemini" | "gemini-cli" => format!("{bin} -p{extra} --approval-mode=auto_edit \"{}\"", escaped),
"cursor" => format!("{bin} chat{extra} --force \"{}\"", escaped),
"droid" => format!("{bin} exec{extra} --auto high \"{}\"", escaped),
"auggie" => format!("{bin}{extra} --print \"{}\"", escaped),
"openclaw" => format!("{bin} agent{extra} --message \"{}\"", escaped),
_ => format!("{bin}{extra} \"{}\"", escaped),
}
}
pub(crate) fn agent_fix_error_msg(hook_event: &HookEvent) -> &'static str {
match hook_event {
HookEvent::CommitMsg => "Commit message validation failed",
_ => "Lint errors detected",
}
}
pub(crate) fn shell_review_box_fn() -> &'static str {
r#"
_print_review_box() {
if [ "$1" = "passed" ]; then
_RH="✓ Linthis 📤 [Pre-push] Review Passed"
_RC="\033[32m"
_RHP=" "
else
_RH="✗ Linthis 📤 [Pre-push] Review Blocked"
_RC="\033[31m"
_RHP=" "
fi
_RN="\033[0m"
_RM=$(printf "%-48s" "$2")
printf "${_RC}╭──────────────────────────────────────────────────╮${_RN}\n" >&2
printf "${_RC}│ ${_RH}${_RHP}│${_RN}\n" >&2
printf "${_RC}├──────────────────────────────────────────────────┤${_RN}\n" >&2
printf "${_RC}│ ${_RM} │${_RN}\n" >&2
if [ "$1" != "passed" ]; then
printf "${_RC}├──────────────────────────────────────────────────┤${_RN}\n" >&2
printf "${_RC}│ To skip this check: │${_RN}\n" >&2
printf "${_RC}│ git push --no-verify │${_RN}\n" >&2
fi
printf "${_RC}╰──────────────────────────────────────────────────╯${_RN}\n" >&2
}
"#
}
pub(crate) fn shell_timer_functions() -> &'static str {
r#"
_linthis_timer_pid=""
start_timer() {
_linthis_label="$1"
printf "[linthis] ⠋ %s (0s)\n" "$_linthis_label" >&2
(
_i=0
_s=0
while true; do
sleep 0.1
_i=$((_i + 1))
case $((_i % 10)) in
0) _spin="⠋" ;;
1) _spin="⠙" ;;
2) _spin="⠹" ;;
3) _spin="⠸" ;;
4) _spin="⠼" ;;
5) _spin="⠴" ;;
6) _spin="⠦" ;;
7) _spin="⠧" ;;
8) _spin="⠇" ;;
9) _spin="⠏" ;;
esac
if [ $((_i % 10)) -eq 0 ]; then
_s=$((_s + 1))
fi
printf "\033[1A\r[linthis] %s %s (%ds)\033[K\n" "$_spin" "$_linthis_label" "$_s" >&2
done
) &
_linthis_timer_pid=$!
}
stop_timer() {
if [ -n "$_linthis_timer_pid" ]; then
kill "$_linthis_timer_pid" 2>/dev/null
wait "$_linthis_timer_pid" 2>/dev/null
_linthis_timer_pid=""
printf "\r\033[K" >&2
fi
}
# ---- IDE "VCS console" colour compensation ----
_LINTHIS_W=""
_LINTHIS_WR=$(printf '\033[0m')
_LINTHIS_R=$(printf '\033[0m')
case "${LINTHIS_HOOK_COLOR:-auto}" in
off)
;;
white)
_LINTHIS_W=$(printf '\033[0;37m')
_LINTHIS_WR=$(printf '\033[0;37m')
;;
auto|*)
if [ ! -t 1 ] \
&& [ -z "$CI" ] \
&& [ -z "$GITHUB_ACTIONS" ] \
&& [ -z "$GITLAB_CI" ] \
&& [ -z "$CIRCLECI" ] \
&& [ -z "$BUILDKITE" ] \
&& [ -z "$CONTINUOUS_INTEGRATION" ]; then
_LINTHIS_W=$(printf '\033[0;37m')
_LINTHIS_WR=$(printf '\033[0;37m')
fi
;;
esac
# Expose paint state to child processes so the outer `linthis hook run`
# (invoked by git, above this script) and any sub-linthis can align without
# re-running the detection. CLICOLOR_FORCE makes `colored`-crate output still
# emit codes under a pipe.
if [ -n "$_LINTHIS_W" ]; then
export LINTHIS_HOOK_PAINT=white
export CLICOLOR_FORCE=1
fi
_linthis_paint() {
if [ -z "$_LINTHIS_W" ]; then
cat
else
# awk filter: replace every `ESC[0m` (reset) with `ESC[0;37m` so text
# after an inner colour block continues in white, then wrap the whole
# line with white + final reset. Existing coloured segments (cyan tips,
# green ✓, red ✗, box borders) keep their colour; only unformatted
# stretches gain the white tint that IDE VCS consoles need.
# gsub takes a regex, so escape the `[` in the RESET pattern.
awk 'BEGIN { ESC = sprintf("\033"); RESET = ESC "[0m"; RESET_RE = ESC "\\[0m"; WHITE = ESC "[0;37m" }
{ gsub(RESET_RE, WHITE); print WHITE $0 RESET; fflush() }'
fi
}
# Run "$@" with stderr folded into stdout, pipe the combined output through
# _linthis_paint, and propagate the command's exit status. A bare pipe would
# report awk's (always 0) exit, which silently broke the caller's
# `if [ $LINTHIS_EXIT -ne 0 ]` flow. Tempfile path is POSIX-portable; no
# PIPESTATUS or `set -o pipefail` (both non-POSIX) required.
_linthis_run_painted() {
if [ -z "$_LINTHIS_W" ]; then
"$@" 2>&1
return $?
fi
_lrp_xf=$(mktemp 2>/dev/null || printf '/tmp/linthis-exit.%d' "$$")
{ "$@" 2>&1; printf '%s\n' "$?" > "$_lrp_xf"; } | _linthis_paint
_lrp_rc=$(cat "$_lrp_xf" 2>/dev/null || echo 1)
rm -f "$_lrp_xf"
return "$_lrp_rc"
}
"#
}
pub(crate) fn prepush_review_prompt() -> &'static str {
"Perform a structured pre-push code review using the lt.review skill. \
Steps: \
(1) Run: BASE_SHA=$(git merge-base HEAD origin/main 2>/dev/null || git rev-parse HEAD~1); \
git diff $BASE_SHA..HEAD --stat; git diff $BASE_SHA..HEAD --name-status; git diff $BASE_SHA..HEAD. \
(2) Review for Critical (security, data loss, broken API, logic errors), \
Important (missing error handling, performance), and Minor issues. \
(3) The review directory is pre-set as $_LINTHIS_REVIEW_DIR (already created and \
resolves to the user's real project slug — DO NOT recompute it from `git rev-parse \
--show-toplevel`, which would return the temporary worktree path inside this hook). \
Write the review to \"$_LINTHIS_REVIEW_DIR/review-$(date +%Y%m%d-%H%M%S).md\". \
(4) If Critical OR Important issues found: save a snapshot with 'git diff', \
auto-fix the issues, then run build/test to verify fixes don't break anything \
(detect project type: cargo check && cargo test for Rust, \
go build ./... && go test ./... for Go, \
npx tsc --noEmit for TypeScript, python -m py_compile for Python). \
If build/tests fail, revert and retry with a different approach. \
CRITICAL: DO NOT run `git commit`, `git add`, or any git command that mutates \
history or the index. Leave fixes as UNCOMMITTED edits in the working tree — \
the linthis hook will capture your diff with `git diff HEAD` and replay it onto \
the user's real project per their fix_commit_mode (squash / fixup / dirty). \
If you commit, the hook sees an empty diff and your fixes never reach the user. \
After fixing, display a Changes Summary showing each file, what changed, and why, \
plus the full git diff (read-only — no commit). Then re-run the review. \
Minor issues are informational — DO NOT auto-fix them; just note them in the \
review report. \
Print '❌ Push blocked — fix Critical issues first' and exit 1 if Critical issues \
remain after auto-fix attempts. \
If only Important issues remain (couldn't fully auto-fix): print '⚠️ Push with \
caution — Important issues remain'. \
If only Minor or none: print '✅ Review passed'. \
Exit 0 unless Critical issues were found."
}
fn shell_review_report_check() -> String {
r#"
# Find the latest review report and check for critical issues.
# Use $_LINTHIS_REVIEW_DIR (exported by worktree_setup) so we hit
# the same path the agent wrote to — single source of truth, and
# robust whether we're inside or outside the worktree.
REVIEW_REPORT=$(ls -t "${_LINTHIS_REVIEW_DIR:-/dev/null}"/review-*.md 2>/dev/null | head -1)
if [ -n "$REVIEW_REPORT" ]; then
# Check for actual critical issues (agent exit code is unreliable)
_CRITICAL=$(awk '/^## Critical Issues/{{found=1;next}} found && /^## /{{found=0}} found && /^- \[/{{print}}' "$REVIEW_REPORT")
if [ -n "$_CRITICAL" ]; then
_print_review_box "blocked" "Critical issues found — fix before pushing"
echo "[linthis] Review saved: $REVIEW_REPORT" >&2
[ -f "$_AGENT_PATCH" ] && rm -f "$_AGENT_PATCH"
exit 1
else
_print_review_box "passed" "No critical issues found"
echo "[linthis] Review saved: $REVIEW_REPORT" >&2
fi
fi
"#
.to_string()
}
fn shell_prepush_agent_fix_in_wt(
linthis_cmd: &str,
fix_provider: &AgentFixProvider,
agent_fix_cmd: &str,
) -> String {
format!(
"# Agent fix on lint failure — runs for ALL fix_commit_modes so the\n\
# agent gets a chance to repair issues before mode-specific handling\n\
# decides whether to block the push or fold fixes into a commit.\n\
# Runs inside the existing worktree so the user's real working tree\n\
# stays untouched while the agent (often minutes-long) is editing.\n\
\x20 _AGENT_FIX_ATTEMPTED=0\n\
\x20 if [ \"$LINTHIS_EXIT\" -ne 0 ] && [ \"$_LINTHIS_AGENT_OK\" = \"1\" ]; then\n\
\x20\x20\x20 _AGENT_FIX_ATTEMPTED=1\n\
\x20\x20\x20 echo \"[linthis] Lint errors detected — invoking {provider} to fix...\" >&2\n\
{worktree_enter}\
\x20\x20\x20 start_timer \"Fixing with {provider}\"\n\
\x20\x20\x20 {agent_fix}\n\
\x20\x20\x20 stop_timer\n\
{worktree_exit_no_cleanup}\
\x20\x20\x20 echo \"[linthis] Re-checking after agent fix...\" >&2\n\
{worktree_enter}\
\x20\x20\x20 _LINT_OUT=$({linthis} \"$@\" 2>&1)\n\
\x20\x20\x20 LINTHIS_EXIT=$?\n\
{worktree_exit_no_cleanup}\
\x20 fi\n",
provider = fix_provider,
agent_fix = agent_fix_cmd,
linthis = linthis_cmd,
worktree_enter = shell_pre_push_worktree_enter(),
worktree_exit_no_cleanup = shell_pre_push_worktree_exit_no_cleanup(),
)
}
fn shell_fast_path_sentinel_check() -> String {
"\x20# Fast-path: when the previous push squashed/fixed-up agent\n\
\x20# changes into HEAD it wrote a sentinel containing the new HEAD\n\
\x20# SHA. If that SHA still matches HEAD, the content was just\n\
\x20# verified clean and reviewed — skip the 5+ minute fix+review\n\
\x20# cycle entirely. Sentinel is consumed (deleted) on use so a\n\
\x20# subsequent unrelated push runs the full flow.\n\
\x20_FAST_REAL_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null)\"\n\
\x20if [ -n \"$_FAST_REAL_ROOT\" ]; then\n\
\x20\x20 _FAST_STUB=$(printf '%s' \"$_FAST_REAL_ROOT\" | tr '/' '-' | sed 's/^-//')\n\
\x20\x20 _FAST_SENTINEL=\"$HOME/.linthis/projects/$_FAST_STUB/pre-push-sentinel\"\n\
\x20\x20 if [ -f \"$_FAST_SENTINEL\" ]; then\n\
\x20\x20\x20 _FAST_SAVED_SHA=$(cat \"$_FAST_SENTINEL\" 2>/dev/null | tr -d '[:space:]')\n\
\x20\x20\x20 _FAST_HEAD_SHA=$(git rev-parse HEAD 2>/dev/null)\n\
\x20\x20\x20 if [ -n \"$_FAST_SAVED_SHA\" ] && [ \"$_FAST_SAVED_SHA\" = \"$_FAST_HEAD_SHA\" ]; then\n\
\x20\x20\x20\x20 echo \"[linthis] Fast-path: HEAD ($_FAST_HEAD_SHA) matches the recent squash/fixup — skipping fix and review.\" >&2\n\
\x20\x20\x20\x20 rm -f \"$_FAST_SENTINEL\" 2>/dev/null\n\
\x20\x20\x20\x20 exit 0\n\
\x20\x20\x20 fi\n\
\x20\x20\x20 # Sentinel exists but HEAD has moved on — stale, drop it.\n\
\x20\x20\x20 rm -f \"$_FAST_SENTINEL\" 2>/dev/null\n\
\x20\x20 fi\n\
\x20 fi\n"
.to_string()
}
pub(crate) fn build_git_with_agent_prepush_script(
linthis_cmd: &str,
fix_provider: &AgentFixProvider,
provider_args: Option<&str>,
) -> String {
let agent_cmd = agent_fix_headless_cmd(fix_provider, prepush_review_prompt(), provider_args);
let agent_fix_cmd = agent_fix_headless_cmd(
fix_provider,
&agent_fix_prompt_for_event(&HookEvent::PrePush),
provider_args,
);
let timer_fns = shell_timer_functions();
let review_box = shell_review_box_fn();
let fix_commit_mode_section = shell_read_fix_commit_mode("pre_push");
let agent_fix_in_wt = shell_prepush_agent_fix_in_wt(linthis_cmd, fix_provider, &agent_fix_cmd);
let footer_dirty_post_fix = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Applied,
hook_type: HookTypeLabel::GitWithAgent,
mode: FixCommitMode::Dirty,
event: &HookEvent::PrePush,
header: "Agent fixes applied to your working tree (dirty mode). \
Push blocked — review the changes, commit, then 'git push' again.",
indent: " ",
});
let fast_path = shell_fast_path_sentinel_check();
format!(
"#!/bin/sh\n\
{timer}\
{review_box}\
{fast_path}\
\n\
# Read fix_commit_mode from config\n\
{fix_commit_mode}\n\
\n\
# Tree-delta semantics: net-no-change files don't need re-linting.\n\
_BASE=$(git rev-parse '@{{u}}' 2>/dev/null || \\\n\
\x20 git merge-base HEAD origin/main 2>/dev/null || \\\n\
\x20 git rev-parse 'HEAD~1' 2>/dev/null)\n\
_PUSHED_FILES=$(git diff --name-only \"$_BASE\"..HEAD 2>/dev/null | grep -v '^$')\n\
\n\
{worktree_setup}\
# Check agent provider availability up-front so both the fix path\n\
# (lint failure → auto-fix) and the review path can gate on it.\n\
{agent_check}\
# Run lint check on pushed files only (skip if no file changes, e.g. empty commits)\n\
# Build -i <file> args for each pushed file (linthis uses -i, not positional paths)\n\
_LINTHIS_CHECKED=0\n\
_AGENT_FIX_ATTEMPTED=0\n\
if [ -n \"$_PUSHED_FILES\" ]; then\n\
\x20 set --\n\
\x20 while IFS= read -r _F; do set -- \"$@\" -i \"$_F\"; done <<_EOF_\n\
$_PUSHED_FILES\n\
_EOF_\n\
{worktree_enter}\
\x20 _LINT_OUT=$({linthis} \"$@\" 2>&1)\n\
\x20 LINTHIS_EXIT=$?\n\
{worktree_exit_no_cleanup}\
{agent_fix_in_wt}\
\x20 printf \"%s\\n\" \"$_LINT_OUT\" >&2\n\
\x20 # Extract actual number of files checked from linthis output\n\
\x20 _LINTHIS_CHECKED=$(printf \"%s\" \"$_LINT_OUT\" | sed -n 's/.*Files checked:[[:space:]]*\\([0-9]*\\).*/\\1/p' | tail -1)\n\
\x20 _LINTHIS_CHECKED=${{_LINTHIS_CHECKED:-0}}\n\
{prepush_fix_commit_mode_handler}\\
fi\n\
\n\
# Skip agent review if no files were actually checked\n\
if [ -z \"$_PUSHED_FILES\" ] || [ \"$_LINTHIS_CHECKED\" = \"0\" ]; then\n\
\x20 echo \"[linthis] No files to review — skipping code review\" >&2\n\
{worktree_leave}\
\x20 exit 0\n\
fi\n\
\n\
# Skip review if agent unavailable (already warned above).\n\
if [ \"$_LINTHIS_AGENT_OK\" = \"0\" ]; then\n\
{worktree_leave}\
\x20 exit 0\n\
fi\n\
\n\
# Re-enter the worktree so the agent review runs in an isolated\n\
# HEAD snapshot — user's real working tree stays untouched for the\n\
# entire (often minutes-long) review.\n\
{worktree_enter}\
echo \"[linthis] Invoking {provider} code review...\" >&2\n\
start_timer \"Reviewing with {provider}\"\n\
{agent}\n\
REVIEW_EXIT=$?\n\
stop_timer\n\
\n\
# Capture the agent's change-set as a patch file (inside the wt).\n\
{capture_agent_patch}\
\n\
# Leave + tear down the worktree BEFORE parsing the review report\n\
# (the report lives under the real project slug, and applying the\n\
# patch needs the real root).\n\
{worktree_leave}\
{review_report_check}\
# Apply the agent's captured patch to the user's real project root\n\
# per fix_commit_mode. If $_AGENT_PATCH is empty (agent made no edits)\n\
# this is a no-op.\n\
{apply_agent_patch}\
\n\
# In dirty mode, an agent fix attempt produces unstaged WT changes\n\
# in the user's real project. The pushed HEAD content is unchanged,\n\
# so we MUST block the push — otherwise the user ships the unfixed\n\
# commit and the agent's fixes sit unstaged. The user commits\n\
# them (or amends HEAD), then re-runs `git push`.\n\
if [ \"$_FIX_MODE\" = \"dirty\" ] && [ \"${{_AGENT_FIX_ATTEMPTED:-0}}\" = \"1\" ]; then\n\
{footer_dirty_post_fix}\
\x20 exit 1\n\
fi\n\
\n\
exit $REVIEW_EXIT\n",
timer = timer_fns,
review_box = review_box,
fast_path = fast_path,
fix_commit_mode = fix_commit_mode_section,
prepush_fix_commit_mode_handler = shell_prepush_fix_commit_mode_handler(linthis_cmd),
capture_agent_patch = shell_capture_agent_patch_in_wt(),
apply_agent_patch = shell_apply_agent_patch_by_mode(),
review_report_check = shell_review_report_check(),
linthis = linthis_cmd,
provider = fix_provider,
agent = agent_cmd,
agent_check = shell_agent_availability_check(fix_provider),
agent_fix_in_wt = agent_fix_in_wt,
footer_dirty_post_fix = footer_dirty_post_fix,
worktree_setup = shell_pre_push_worktree_setup(),
worktree_enter = shell_pre_push_worktree_enter(),
worktree_exit_no_cleanup = shell_pre_push_worktree_exit_no_cleanup(),
worktree_leave = shell_pre_push_worktree_leave(),
)
}
fn shell_worktree_agent_fix(
linthis_cmd: &str,
fix_provider: &AgentFixProvider,
agent_cmd: &str,
error_msg: &str,
) -> String {
let agent_check = shell_agent_availability_check(fix_provider);
let agent_block = shell_agent_invoke_block(fix_provider, agent_cmd, error_msg, " ");
format!(
"\x20 # Check if agent provider is available before attempting fix\n\
\x20 {agent_check}\
\x20 if [ \"$_LINTHIS_AGENT_OK\" = \"1\" ]; then\n\
\x20\x20\x20 # Backup staged files (safety net for linthis backup undo hook)\n\
\x20\x20\x20 if [ -n \"$_STAGED_FILES\" ]; then\n\
\x20\x20\x20\x20\x20 echo \"$_STAGED_FILES\" | tr '\\n' '\\0' | xargs -0 {linthis} backup create -d \"hook-agent-fix\" 2>/dev/null\n\
\x20\x20\x20 fi\n\
\x20\x20\x20 # Agent fixes directly in main working tree (backup provides safety net)\n\
{agent_block}\
\x20\x20\x20 if [ \"$_AGENT_RAN\" = \"1\" ]; then\n\
{agent_hint}\
\x20\x20\x20\x20\x20 # Re-stage files modified by agent\n\
\x20\x20\x20\x20\x20 if [ -n \"$_STAGED_FILES\" ]; then\n\
\x20\x20\x20\x20\x20\x20\x20 echo \"$_STAGED_FILES\" | xargs git add\n\
\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20\x20\x20 # Re-verify after agent fix\n\
\x20\x20\x20\x20\x20 printf \"${{_LINTHIS_W}}[linthis] Re-verifying...${{_LINTHIS_R}}\\n\"\n\
\x20\x20\x20\x20\x20 _linthis_run_painted $LINTHIS_CMD\n\
\x20\x20\x20\x20\x20 LINTHIS_EXIT=$?\n\
\x20\x20\x20 fi\n\
\x20 fi\n",
agent_check = agent_check,
agent_block = agent_block,
agent_hint = shell_agent_fix_hint(" "),
linthis = linthis_cmd,
)
}
fn shell_sandbox_setup() -> String {
"\x20\x20\x20 # Path B sandbox: can't `git worktree add` during pre-commit\n\
\x20\x20\x20 # (index.lock is held), so mirror staged content into a plain\n\
\x20\x20\x20 # tempdir instead. User's real working tree is untouched for\n\
\x20\x20\x20 # the entire agent run.\n\
\x20\x20\x20 _SB_REAL_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null)\"\n\
\x20\x20\x20 _SB_CWD=\"$(pwd)\"\n\
\x20\x20\x20 _SANDBOX=\"\"\n\
\x20\x20\x20 if [ -n \"$_SB_REAL_ROOT\" ]; then\n\
\x20\x20\x20\x20\x20 # Use $TMPDIR to stay OUTSIDE any `.linthis/` exclude tree.\n\
\x20\x20\x20\x20\x20 _SB_BASE=\"${TMPDIR:-/tmp}\"\n\
\x20\x20\x20\x20\x20 # Prune our own orphans >60 min old (Ctrl-C'd runs).\n\
\x20\x20\x20\x20\x20 find \"$_SB_BASE\" -maxdepth 1 -name 'linthis-sandbox-*' -type d -mmin +60 2>/dev/null -exec rm -rf {} + 2>/dev/null\n\
\x20\x20\x20\x20\x20 _SANDBOX=\"$_SB_BASE/linthis-sandbox-$(date +%s)-$$\"\n\
\x20\x20\x20\x20\x20 mkdir -p \"$_SANDBOX\"\n\
\x20\x20\x20\x20\x20 _sb_cleanup() { [ -n \"$_SANDBOX\" ] && [ -d \"$_SANDBOX\" ] && rm -rf \"$_SANDBOX\" 2>/dev/null; }\n\
\x20\x20\x20\x20\x20 trap '_sb_cleanup' EXIT HUP INT TERM\n\
\x20\x20\x20 fi\n"
.to_string()
}
fn shell_sandbox_materialize() -> String {
"\x20\x20\x20 if [ -n \"$_SANDBOX\" ]; then\n\
\x20\x20\x20\x20\x20 while IFS= read -r _F; do\n\
\x20\x20\x20\x20\x20\x20\x20 [ -z \"$_F\" ] && continue\n\
\x20\x20\x20\x20\x20\x20\x20 _SB_DIR=$(dirname \"$_F\")\n\
\x20\x20\x20\x20\x20\x20\x20 [ \"$_SB_DIR\" != \".\" ] && mkdir -p \"$_SANDBOX/$_SB_DIR\"\n\
\x20\x20\x20\x20\x20\x20\x20 git show \":$_F\" > \"$_SANDBOX/$_F\" 2>/dev/null || :\n\
\x20\x20\x20\x20\x20 done <<_SB_MATERIALIZE_EOF_\n\
$_STAGED_FILES\n\
_SB_MATERIALIZE_EOF_\n\
\x20\x20\x20\x20\x20 ( cd \"$_SANDBOX\" && git init -q && git -c user.email=linthis@local -c user.name=linthis add -A ) 2>/dev/null\n\
\x20\x20\x20 fi\n"
.to_string()
}
fn shell_sandbox_enter_or_fallback() -> String {
"\x20\x20\x20 if [ -n \"$_SANDBOX\" ] && [ -d \"$_SANDBOX/.git\" ]; then\n\
\x20\x20\x20\x20\x20 cd \"$_SANDBOX\" || true\n\
\x20\x20\x20 else\n\
\x20\x20\x20\x20\x20 echo \"[linthis] ⚠ Couldn't set up sandbox — running agent on working tree (backup provides safety net).\" >&2\n\
\x20\x20\x20 fi\n"
.to_string()
}
fn shell_sandbox_capture_and_apply(footer_conflict: &str) -> String {
format!(
"\x20\x20\x20\x20\x20 if [ -n \"$_SANDBOX\" ] && [ -d \"$_SANDBOX/.git\" ]; then\n\
\x20\x20\x20\x20\x20\x20\x20 _SB_PATCH=$(mktemp \"${{TMPDIR:-/tmp}}/linthis-sandbox.XXXXXX\" 2>/dev/null || mktemp)\n\
\x20\x20\x20\x20\x20\x20\x20 # WT vs index (no HEAD since materialize skipped commit) —\n\
\x20\x20\x20\x20\x20\x20\x20 # captures exactly the agent's modifications.\n\
\x20\x20\x20\x20\x20\x20\x20 ( cd \"$_SANDBOX\" && git diff --binary ) > \"$_SB_PATCH\" 2>/dev/null\n\
\x20\x20\x20\x20\x20\x20\x20 cd \"$_SB_REAL_ROOT\" >/dev/null 2>&1 || cd \"$_SB_CWD\" >/dev/null 2>&1 || true\n\
\x20\x20\x20\x20\x20\x20\x20 if [ -s \"$_SB_PATCH\" ]; then\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20 _SB_APPLY_ERR=$(mktemp \"${{TMPDIR:-/tmp}}/linthis-apply-err.XXXXXX\" 2>/dev/null || mktemp)\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20 # Plain `--binary` apply — `--3way` would imply `--index`\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20 # and trip on blob-hash mismatch (see post-commit fix).\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20 if ! git apply --binary --whitespace=nowarn \"$_SB_PATCH\" 2>\"$_SB_APPLY_ERR\"; then\n\
{footer_conflict}\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20 if [ -s \"$_SB_APPLY_ERR\" ]; then\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20 echo \"[linthis] git apply error:\" >&2\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20 sed 's/^/[linthis] /' \"$_SB_APPLY_ERR\" >&2\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20 echo \"[linthis] Patch kept at: $_SB_PATCH\" >&2\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20 else\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20 rm -f \"$_SB_PATCH\" 2>/dev/null\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20 rm -f \"$_SB_APPLY_ERR\" 2>/dev/null\n\
\x20\x20\x20\x20\x20\x20\x20 else\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20 rm -f \"$_SB_PATCH\" 2>/dev/null\n\
\x20\x20\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20\x20\x20\x20\x20 _sb_cleanup\n\
\x20\x20\x20\x20\x20\x20\x20 trap - EXIT HUP INT TERM\n\
\x20\x20\x20\x20\x20 fi\n"
)
}
fn shell_sandbox_teardown_if_unused() -> String {
"\x20\x20\x20\x20\x20 if [ -n \"$_SANDBOX\" ]; then\n\
\x20\x20\x20\x20\x20\x20\x20 cd \"$_SB_REAL_ROOT\" >/dev/null 2>&1 || cd \"$_SB_CWD\" >/dev/null 2>&1 || true\n\
\x20\x20\x20\x20\x20\x20\x20 _sb_cleanup\n\
\x20\x20\x20\x20\x20\x20\x20 trap - EXIT HUP INT TERM\n\
\x20\x20\x20\x20\x20 fi\n"
.to_string()
}
fn shell_sandbox_agent_fix(
linthis_cmd: &str,
fix_provider: &AgentFixProvider,
agent_cmd: &str,
error_msg: &str,
) -> String {
let agent_check = shell_agent_availability_check(fix_provider);
let agent_block = shell_agent_invoke_block(fix_provider, agent_cmd, error_msg, " ");
let footer_conflict = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Conflict,
hook_type: HookTypeLabel::GitWithAgent,
mode: FixCommitMode::Squash,
event: &HookEvent::PreCommit,
header:
"⚠ Agent's fix conflicts with your uncommitted edits. Patch kept — resolve manually.",
indent: " ",
});
let sb_setup = shell_sandbox_setup();
let sb_materialize = shell_sandbox_materialize();
let sb_enter = shell_sandbox_enter_or_fallback();
let sb_capture_apply = shell_sandbox_capture_and_apply(&footer_conflict);
let sb_teardown_unused = shell_sandbox_teardown_if_unused();
format!(
"\x20 {agent_check}\
\x20 if [ \"$_LINTHIS_AGENT_OK\" = \"1\" ]; then\n\
\x20\x20\x20 # Backup staged files (safety net for `linthis backup undo`)\n\
\x20\x20\x20 if [ -n \"$_STAGED_FILES\" ]; then\n\
\x20\x20\x20\x20\x20 echo \"$_STAGED_FILES\" | tr '\\n' '\\0' | xargs -0 {linthis} backup create -d \"hook-agent-fix\" 2>/dev/null\n\
\x20\x20\x20 fi\n\
{sb_setup}\
{sb_materialize}\
{sb_enter}\
{agent_block}\
\x20\x20\x20 if [ \"$_AGENT_RAN\" = \"1\" ]; then\n\
{sb_capture_apply}\
{agent_hint}\
\x20\x20\x20\x20\x20 if [ -n \"$_STAGED_FILES\" ]; then\n\
\x20\x20\x20\x20\x20\x20\x20 echo \"$_STAGED_FILES\" | xargs git add\n\
\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20\x20\x20 printf \"${{_LINTHIS_W}}[linthis] Re-verifying...${{_LINTHIS_R}}\\n\"\n\
\x20\x20\x20\x20\x20 _linthis_run_painted $LINTHIS_CMD\n\
\x20\x20\x20\x20\x20 LINTHIS_EXIT=$?\n\
\x20\x20\x20 else\n\
{sb_teardown_unused}\
\x20\x20\x20 fi\n\
\x20 fi\n",
agent_check = agent_check,
agent_block = agent_block,
agent_hint = shell_agent_fix_hint(" "),
linthis = linthis_cmd,
)
}
fn build_git_with_agent_commitmsg_script(
linthis_cmd: &str,
fix_provider: &AgentFixProvider,
provider_args: Option<&str>,
) -> String {
let agent_cmd = agent_fix_headless_cmd_commit_msg(fix_provider, provider_args);
let error_msg = agent_fix_error_msg(&HookEvent::CommitMsg);
let timer_fns = shell_timer_functions();
let new_msg_print = agent_fix_show_fixed_cmsg(" ");
let worktree_fix = shell_worktree_agent_fix(linthis_cmd, fix_provider, &agent_cmd, error_msg);
format!(
"#!/bin/sh\n\
{timer}\
LINTHIS_CMD=\"{linthis}\"\n\
_STAGED_FILES=$(git diff --cached --name-only)\n\
\n\
if [ -z \"$_STAGED_FILES\" ]; then\n\
\x20 exit 0\n\
fi\n\
\n\
_linthis_run_painted $LINTHIS_CMD\n\
LINTHIS_EXIT=$?\n\
if [ -n \"$_STAGED_FILES\" ]; then\n\
\x20 echo \"$_STAGED_FILES\" | xargs git add\n\
fi\n\
\n\
if [ $LINTHIS_EXIT -ne 0 ]; then\n\
{worktree_fix}\
{new_msg_print}\
fi\n\
\n\
exit $LINTHIS_EXIT\n",
timer = timer_fns,
linthis = linthis_cmd,
worktree_fix = worktree_fix,
new_msg_print = new_msg_print,
)
}
pub(crate) fn build_git_with_agent_hook_script(
linthis_cmd: &str,
fix_provider: &AgentFixProvider,
hook_event: &HookEvent,
provider_args: Option<&str>,
) -> String {
if matches!(hook_event, HookEvent::PrePush) {
return build_git_with_agent_prepush_script(linthis_cmd, fix_provider, provider_args);
}
if matches!(hook_event, HookEvent::CommitMsg) {
return build_git_with_agent_commitmsg_script(linthis_cmd, fix_provider, provider_args);
}
let prompt = agent_fix_prompt_for_event(hook_event);
let agent_cmd = agent_fix_headless_cmd(fix_provider, &prompt, provider_args);
let error_msg = agent_fix_error_msg(hook_event);
let timer_fns = shell_timer_functions();
let worktree_fix = shell_sandbox_agent_fix(linthis_cmd, fix_provider, &agent_cmd, error_msg);
let fix_commit_mode_section = shell_read_fix_commit_mode("pre_commit");
let linthis_check_only = linthis_cmd.replace("-c -f", "-c");
let save_diff = shell_save_diff_patch();
format!(
"#!/bin/sh\n\
{timer}\
_STAGED_FILES=$(git diff --cached --name-only)\n\
\n\
# Skip entirely if no staged files (empty commit)\n\
if [ -z \"$_STAGED_FILES\" ]; then\n\
\x20 exit 0\n\
fi\n\
\n\
# Read fix_commit_mode from config\n\
{fix_commit_mode}\
\n\
if [ \"$_FIX_MODE\" = \"fixup\" ]; then\n\
\x20 # fixup: check only, let commit through, post-commit handles format\n\
\x20 _linthis_run_painted {linthis_check_only}\n\
{sentinel_write}\
\x20 exit 0\n\
fi\n\
\n\
# squash / dirty: run check + format\n\
# Snapshot pre-format state for stash (squash mode)\n\
if [ \"$_FIX_MODE\" = \"squash\" ]; then\n\
\x20 _STASH_REF=$(git stash create 2>/dev/null)\n\
fi\n\
\n\
LINTHIS_CMD=\"{linthis}\"\n\
_linthis_run_painted $LINTHIS_CMD\n\
LINTHIS_EXIT=$?\n\
\n\
# Agent fallback runs BEFORE mode-specific handling so BOTH\n\
# `dirty` and `squash` modes get a chance to auto-fix via the\n\
# agent before we decide whether to block the commit. Earlier\n\
# the dirty branch exited 1 before reaching this block, so the\n\
# agent never ran for dirty + --type git-with-agent.\n\
if [ $LINTHIS_EXIT -ne 0 ]; then\n\
{worktree_fix}\
fi\n\
\n\
{save_diff}\
if [ \"$_FIX_MODE\" = \"squash\" ]; then\n\
\x20 # Re-stage files modified by linthis -f (auto-format) or agent.\n\
\x20 if [ -n \"$_STAGED_FILES\" ]; then\n\
\x20\x20\x20 echo \"$_STAGED_FILES\" | xargs git add\n\
\x20 fi\n\
\x20 # Save stash if files were formatted\n\
\x20 if [ -n \"$_STASH_REF\" ]; then\n\
\x20\x20\x20 git stash store -m \"linthis: pre-format snapshot\" \"$_STASH_REF\" 2>/dev/null\n\
\x20 fi\n\
\x20 if [ \"$LINTHIS_EXIT\" -eq 0 ]; then\n\
{footer_squash_success}\
\x20 else\n\
\x20\x20\x20 echo \"[linthis] Format fixes staged — will fold into your next 'git commit' (squash mode).\" >&2\n\
{footer_squash_blocked}\
\x20 fi\n\
elif [ \"$_FIX_MODE\" = \"dirty\" ]; then\n\
\x20 # dirty: do NOT re-stage, block commit if files changed\n\
\x20 _DIRTY=$(git diff --name-only)\n\
\x20 if [ -n \"$_DIRTY\" ]; then\n\
{footer_dirty}\
\x20\x20\x20 exit 1\n\
\x20 fi\n\
fi\n\
\n\
exit $LINTHIS_EXIT\n",
timer = timer_fns,
fix_commit_mode = fix_commit_mode_section,
linthis_check_only = linthis_check_only,
linthis = linthis_cmd,
worktree_fix = worktree_fix,
save_diff = save_diff,
footer_squash_success = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Applied,
hook_type: HookTypeLabel::GitWithAgent,
mode: FixCommitMode::Squash,
event: &HookEvent::PreCommit,
header: "Format fixes folded into your commit (squash mode — single commit).",
indent: " ",
}),
footer_squash_blocked = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Blocked,
hook_type: HookTypeLabel::GitWithAgent,
mode: FixCommitMode::Squash,
event: &HookEvent::PreCommit,
header: "✗ Unfixable lint issues above — THIS commit blocked. Fix manually, then 'git commit' again.",
indent: " ",
}),
footer_dirty = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Applied,
hook_type: HookTypeLabel::GitWithAgent,
mode: FixCommitMode::Dirty,
event: &HookEvent::PreCommit,
header: "Files formatted but not staged (dirty mode).",
indent: " ",
}),
sentinel_write = shell_write_pending_fixup_sentinel(" "),
)
}
fn shell_prepush_fix_commit_mode_handler(linthis_cmd: &str) -> String {
let precise = shell_prepush_precise_flow(linthis_cmd);
let footer_dirty_blocked = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Blocked,
hook_type: HookTypeLabel::GitWithAgent,
mode: FixCommitMode::Dirty,
event: &HookEvent::PrePush,
header: "✗ Lint check failed (dirty mode) — push blocked. Fix the issues above and retry.",
indent: " ",
});
format!(
"\x20 # Handle fix_commit_mode for pre-push\n\
\x20 if [ \"$LINTHIS_EXIT\" -ne 0 ] && [ \"$_FIX_MODE\" = \"dirty\" ]; then\n\
{footer_dirty_blocked}\
\x20\x20\x20 exit $LINTHIS_EXIT\n\
\x20 fi\n\
\x20 if [ \"$LINTHIS_EXIT\" -ne 0 ] && {{ [ \"$_FIX_MODE\" = \"squash\" ] || [ \"$_FIX_MODE\" = \"fixup\" ]; }}; then\n\
{precise}\
\x20 fi\n",
)
}
fn shell_prepush_precise_flow(linthis_cmd: &str) -> String {
let setup = shell_precise_setup_and_trap();
let reset_format = shell_precise_reset_and_format(linthis_cmd);
let stage = shell_precise_stage_scoped();
let commit_block = shell_precise_commit_and_restore();
let skip_block = shell_precise_skip_restore();
format!(
"\x20\x20\x20 # Precise-fixup flow: isolate linthis's format diff from user's\n\
\x20\x20\x20 # uncommitted work so the commit contains ONLY format changes.\n\
{setup}\
{reset_format}\
{stage}\
\x20\x20\x20 _SCOPED_STAGED=$(git diff --cached --name-only)\n\
\x20\x20\x20 if [ -n \"$_SCOPED_STAGED\" ]; then\n\
{commit_block}\
\x20\x20\x20 fi\n\
\x20\x20\x20 \n\
{skip_block}",
)
}
fn shell_precise_setup_and_trap() -> String {
"\x20\x20\x20 _USER_STASH=$(git stash create 2>/dev/null || echo \"\")\n\
\x20\x20\x20 _LINTHIS_PATCH=$(mktemp \"${TMPDIR:-/tmp}/linthis-precise.XXXXXX\" 2>/dev/null || mktemp)\n\
\x20\x20\x20 _PRECISE_RESTORED=0\n\
\x20\x20\x20 \n\
\x20\x20\x20 _linthis_precise_cleanup() {\n\
\x20\x20\x20\x20\x20 if [ \"$_PRECISE_RESTORED\" = \"0\" ] && [ -n \"$_USER_STASH\" ]; then\n\
\x20\x20\x20\x20\x20\x20\x20 git stash apply --index \"$_USER_STASH\" >/dev/null 2>&1 || \\\n\
\x20\x20\x20\x20\x20\x20\x20 git stash store -m \"linthis: pre-push recovery snapshot\" \"$_USER_STASH\" >/dev/null 2>&1 || true\n\
\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20\x20\x20 [ -f \"$_LINTHIS_PATCH\" ] && rm -f \"$_LINTHIS_PATCH\"\n\
\x20\x20\x20 }\n\
\x20\x20\x20 trap '_linthis_precise_cleanup' HUP INT TERM\n\
\x20\x20\x20 \n"
.to_string()
}
fn shell_precise_reset_and_format(linthis_cmd: &str) -> String {
format!(
"\x20\x20\x20 # Phase 1: clear index + reset pushed files' working tree to HEAD\n\
\x20\x20\x20 git reset -q HEAD -- . >/dev/null 2>&1 || true\n\
\x20\x20\x20 while IFS= read -r _F; do\n\
\x20\x20\x20 [ -z \"$_F\" ] && continue\n\
\x20\x20\x20 git checkout HEAD -- \"$_F\" >/dev/null 2>&1 || true\n\
\x20\x20\x20 done <<_PRECISE_RESET_EOF_\n\
$_PUSHED_FILES\n\
_PRECISE_RESET_EOF_\n\
\x20\x20\x20 \n\
\x20\x20\x20 # Phase 2: format the clean HEAD content\n\
\x20\x20\x20 {linthis} \"$@\" -f 2>&1 || true\n\
\x20\x20\x20 \n\
\x20\x20\x20 # Phase 3: capture the format-only diff (HEAD → formatted)\n\
\x20\x20\x20 set --\n\
\x20\x20\x20 while IFS= read -r _F; do\n\
\x20\x20\x20 [ -z \"$_F\" ] && continue\n\
\x20\x20\x20 set -- \"$@\" \"$_F\"\n\
\x20\x20\x20 done <<_PRECISE_LIST_EOF_\n\
$_PUSHED_FILES\n\
_PRECISE_LIST_EOF_\n\
\x20\x20\x20 git diff --binary -- \"$@\" > \"$_LINTHIS_PATCH\" 2>/dev/null || true\n\
\x20\x20\x20 \n",
linthis = linthis_cmd,
)
}
fn shell_precise_stage_scoped() -> String {
"\x20\x20\x20 # Phase 4: stage linthis's format (scoped to pushed files)\n\
\x20\x20\x20 while IFS= read -r _F; do\n\
\x20\x20\x20 [ -z \"$_F\" ] && continue\n\
\x20\x20\x20 git add -- \"$_F\" >/dev/null 2>&1 || true\n\
\x20\x20\x20 done <<_PRECISE_ADD_EOF_\n\
$_PUSHED_FILES\n\
_PRECISE_ADD_EOF_\n\
\x20\x20\x20 \n"
.to_string()
}
fn shell_precise_commit_and_restore() -> String {
let save_diff_cached = shell_save_diff_patch_cached();
let footer_squash = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Applied,
hook_type: HookTypeLabel::GitWithAgent,
mode: FixCommitMode::Squash,
event: &HookEvent::PrePush,
header:
"Lint fixes squashed into latest commit (format-only). Review, then 'git push' again.",
indent: " ",
});
let footer_fixup = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Applied,
hook_type: HookTypeLabel::GitWithAgent,
mode: FixCommitMode::Fixup,
event: &HookEvent::PrePush,
header: "Created fixup commit (format-only). Review, then 'git push' again.",
indent: " ",
});
let footer_conflict_squash = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Conflict,
hook_type: HookTypeLabel::GitWithAgent,
mode: FixCommitMode::Squash,
event: &HookEvent::PrePush,
header: "⚠ Your uncommitted changes overlap with format fixes. Working tree has conflict markers — resolve, then 'git push' again. Recovery: 'git stash list' (find 'linthis: uncommitted').",
indent: " ",
});
format!(
"\x20\x20\x20\x20\x20 {save_diff_cached}\
\x20\x20\x20\x20\x20 # Phase 5: commit (mode-specific)\n\
\x20\x20\x20\x20\x20 if [ \"$_FIX_MODE\" = \"squash\" ]; then\n\
\x20\x20\x20\x20\x20\x20\x20 git commit --no-verify -m \"fix(linthis): auto-fix lint issues\" >/dev/null 2>&1\n\
\x20\x20\x20\x20\x20\x20\x20 git reset --soft HEAD~2 >/dev/null 2>&1\n\
\x20\x20\x20\x20\x20\x20\x20 git commit --no-verify -C HEAD@{{2}} >/dev/null 2>&1\n\
{footer_squash}\
\x20\x20\x20\x20\x20 else\n\
\x20\x20\x20\x20\x20\x20\x20 git commit --no-verify -m \"fix(linthis): auto-fix lint issues\" >/dev/null 2>&1\n\
{footer_fixup}\
\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20\x20\x20 \n\
\x20\x20\x20\x20\x20 # Phase 6: restore user state (staged + unstaged) via 3-way merge.\n\
\x20\x20\x20\x20\x20 if [ -n \"$_USER_STASH\" ]; then\n\
\x20\x20\x20\x20\x20\x20\x20 if ! git stash apply --index \"$_USER_STASH\" >/dev/null 2>&1; then\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20 git stash store -m \"linthis: uncommitted state before fixup\" \"$_USER_STASH\" >/dev/null 2>&1 || true\n\
{footer_conflict_squash}\
\x20\x20\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20\x20\x20 _PRECISE_RESTORED=1\n\
\x20\x20\x20\x20\x20 rm -f \"$_LINTHIS_PATCH\"\n\
\x20\x20\x20\x20\x20 trap - HUP INT TERM\n\
\x20\x20\x20\x20\x20 exit 1\n",
)
}
fn shell_precise_skip_restore() -> String {
"\x20\x20\x20 # Nothing staged by linthis — restore user state and continue.\n\
\x20\x20\x20 if [ -n \"$_USER_STASH\" ]; then\n\
\x20\x20\x20\x20\x20 git stash apply --index \"$_USER_STASH\" >/dev/null 2>&1 || \\\n\
\x20\x20\x20\x20\x20 git stash store -m \"linthis: pre-push recovery snapshot\" \"$_USER_STASH\" >/dev/null 2>&1 || true\n\
\x20\x20\x20 fi\n\
\x20\x20\x20 _PRECISE_RESTORED=1\n\
\x20\x20\x20 rm -f \"$_LINTHIS_PATCH\"\n\
\x20\x20\x20 trap - HUP INT TERM\n"
.to_string()
}
fn shell_git_fix_commit_mode_handler(hook_event: &HookEvent) -> String {
if matches!(hook_event, HookEvent::PreCommit) {
let save_diff = shell_save_diff_patch();
let footer_squash_success = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Applied,
hook_type: HookTypeLabel::Git,
mode: FixCommitMode::Squash,
event: &HookEvent::PreCommit,
header: "Format fixes folded into your commit (squash mode — single commit).",
indent: " ",
});
let footer_squash_blocked_after_format = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Blocked,
hook_type: HookTypeLabel::Git,
mode: FixCommitMode::Squash,
event: &HookEvent::PreCommit,
header: "✗ Unfixable lint issues above — THIS commit blocked. Fix manually, then 'git commit' again.",
indent: " ",
});
let footer_squash_blocked_no_format = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Blocked,
hook_type: HookTypeLabel::Git,
mode: FixCommitMode::Squash,
event: &HookEvent::PreCommit,
header: "✗ Lint check failed (squash mode) — commit blocked. Fix the errors above and retry.",
indent: " ",
});
let footer_dirty = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Applied,
hook_type: HookTypeLabel::Git,
mode: FixCommitMode::Dirty,
event: &HookEvent::PreCommit,
header: "Files formatted but not staged (dirty mode).",
indent: " ",
});
format!(
"\x20\x20\x20 _STAGED_FILES=$(git diff --cached --name-only)\n\
\x20\x20\x20 _DIRTY=$(git diff --name-only)\n\
\x20\x20\x20 if [ \"$_FIX_MODE\" = \"squash\" ] && [ -n \"$_DIRTY\" ]; then\n\
\x20\x20\x20\x20\x20 {save_diff}\
\x20\x20\x20\x20\x20 echo \"$_STAGED_FILES\" | xargs git add\n\
\x20\x20\x20\x20\x20 # Save stash snapshot\n\
\x20\x20\x20\x20\x20 if [ -n \"$_STASH_REF\" ]; then\n\
\x20\x20\x20\x20\x20\x20\x20 git stash store -m \"linthis: pre-format snapshot\" \"$_STASH_REF\" 2>/dev/null\n\
\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20\x20\x20 if [ \"$LINTHIS_EXIT\" -eq 0 ]; then\n\
{footer_squash_success}\
\x20\x20\x20\x20\x20 else\n\
\x20\x20\x20\x20\x20\x20\x20 echo \"[linthis] Format fixes staged — will fold into your next 'git commit' (squash mode).\" >&2\n\
{footer_squash_blocked_after_format}\
\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20 elif [ \"$_FIX_MODE\" = \"squash\" ] && [ -n \"$_STAGED_FILES\" ]; then\n\
\x20\x20\x20\x20\x20 echo \"$_STAGED_FILES\" | xargs git add\n\
\x20\x20\x20\x20\x20 if [ \"$LINTHIS_EXIT\" -ne 0 ]; then\n\
{footer_squash_blocked_no_format}\
\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20 elif [ \"$_FIX_MODE\" = \"dirty\" ]; then\n\
\x20\x20\x20\x20\x20 _DIRTY=$(git diff --name-only)\n\
\x20\x20\x20\x20\x20 if [ -n \"$_DIRTY\" ]; then\n\
\x20\x20\x20\x20\x20\x20\x20 {save_diff}\
{footer_dirty}\
\x20\x20\x20\x20\x20\x20\x20 exit 1\n\
\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20 elif [ \"$_FIX_MODE\" = \"fixup\" ]; then\n\
\x20\x20\x20\x20\x20 # fixup for git type: check only, no format (post-commit handles it)\n\
{sentinel_write}\
\x20\x20\x20 fi\n",
save_diff = save_diff,
sentinel_write = shell_write_pending_fixup_sentinel(" "),
)
} else if matches!(hook_event, HookEvent::PrePush) {
let footer_clean = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Clean,
hook_type: HookTypeLabel::Git,
mode: FixCommitMode::Squash,
event: &HookEvent::PrePush,
header: "✓ Pre-push check passed — push proceeding.",
indent: " ",
});
let footer_blocked = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Blocked,
hook_type: HookTypeLabel::Git,
mode: FixCommitMode::Squash,
event: &HookEvent::PrePush,
header: "✗ Pre-push check failed — push blocked. Fix the errors above and retry.",
indent: " ",
});
format!(
"\x20 # --type git pre-push: report result explicitly.\n\
\x20 if [ \"$LINTHIS_EXIT\" -eq 0 ]; then\n\
{footer_clean}\
\x20 else\n\
{footer_blocked}\
\x20 fi\n",
)
} else {
String::new()
}
}
fn shell_save_diff_patch() -> String {
let keep = load_retention_diffs();
format!(
"# Save linthis changes as a git patch for review/revert\n\
_SLUG=$(git rev-parse --show-toplevel 2>/dev/null | tr '/' '-' | sed 's/^-//')\n\
_DIFF_DIR=\"$HOME/.linthis/projects/$_SLUG/diff\"\n\
mkdir -p \"$_DIFF_DIR\"\n\
_DIFF_FILE=\"$_DIFF_DIR/diff-$(date +%Y%m%d-%H%M%S).patch\"\n\
git diff > \"$_DIFF_FILE\" 2>/dev/null\n\
if [ -s \"$_DIFF_FILE\" ]; then\n\
\x20 echo \"[linthis] Patch saved: $_DIFF_FILE\" >&2\n\
\x20 echo \" Revert: git apply -R $_DIFF_FILE\" >&2\n\
\x20 ls -t \"$_DIFF_DIR\"/diff-*.patch 2>/dev/null | tail -n +{keep_plus_one} | xargs rm -f 2>/dev/null\n\
else\n\
\x20 rm -f \"$_DIFF_FILE\"\n\
fi\n",
keep_plus_one = keep + 1,
)
}
fn shell_save_diff_patch_cached() -> String {
let keep = load_retention_diffs();
format!(
"# Save linthis changes as a git patch for review/revert\n\
_SLUG=$(git rev-parse --show-toplevel 2>/dev/null | tr '/' '-' | sed 's/^-//')\n\
_DIFF_DIR=\"$HOME/.linthis/projects/$_SLUG/diff\"\n\
mkdir -p \"$_DIFF_DIR\"\n\
_DIFF_FILE=\"$_DIFF_DIR/diff-$(date +%Y%m%d-%H%M%S).patch\"\n\
git diff --cached > \"$_DIFF_FILE\" 2>/dev/null\n\
if [ -s \"$_DIFF_FILE\" ]; then\n\
\x20 echo \"[linthis] Patch saved: $_DIFF_FILE\" >&2\n\
\x20 echo \" Revert: git apply -R $_DIFF_FILE\" >&2\n\
\x20 ls -t \"$_DIFF_DIR\"/diff-*.patch 2>/dev/null | tail -n +{keep_plus_one} | xargs rm -f 2>/dev/null\n\
else\n\
\x20 rm -f \"$_DIFF_FILE\"\n\
fi\n",
keep_plus_one = keep + 1,
)
}
fn load_retention_diffs() -> usize {
let project_root = linthis::utils::get_project_root();
linthis::config::Config::load_merged(&project_root)
.retention
.diffs
}
fn shell_restage_committed_scope(indent: &str) -> String {
format!(
"{i}# Re-stage only files in the committed scope that were further modified.\n\
{i}# (Prevents unrelated unstaged working-tree changes from leaking into the fixup commit.)\n\
{i}while IFS= read -r _F; do\n\
{i} [ -z \"$_F\" ] && continue\n\
{i} git add -- \"$_F\" 2>/dev/null || true\n\
{i}done <<_RESTAGE_EOF_\n\
$_FILES\n\
_RESTAGE_EOF_\n",
i = indent,
)
}
fn shell_read_fix_commit_mode(config_section: &str) -> String {
let default = if config_section == "pre_commit" {
"squash"
} else {
"dirty"
};
format!(
"_FIX_MODE=$(linthis config get hook.{section}.fix_commit_mode 2>/dev/null || \
linthis config get hook.{section}.fix_commit_mode --global 2>/dev/null || \
echo \"{default}\")\n",
section = config_section,
default = default,
)
}
pub(crate) fn build_post_commit_script(linthis_cmd: &str) -> String {
let timer_fns = shell_timer_functions();
let fix_commit_mode_section = shell_read_fix_commit_mode("pre_commit");
let restage_scope = shell_restage_committed_scope(" ");
format!(
"#!/bin/sh\n\
{timer}\
# Read fix_commit_mode — only activate in fixup mode\n\
{fix_commit_mode}\
if [ \"$_FIX_MODE\" != \"fixup\" ]; then\n\
\x20 exit 0\n\
fi\n\
\n\
# Get files from the commit that was just created\n\
_FILES=$(git diff-tree --no-commit-id --name-only -r HEAD)\n\
[ -z \"$_FILES\" ] && exit 0\n\
\n\
# Build -i <file> args and run linthis once for all committed files\n\
# (single invocation = single [Post-commit] output box)\n\
set --\n\
while IFS= read -r _F; do set -- \"$@\" -i \"$_F\"; done <<_EOF_\n\
$_FILES\n\
_EOF_\n\
_linthis_run_painted {linthis} \"$@\"\n\
\n\
# Restrict staging to the committed scope, then make the fixup commit\n\
# only if that scope actually changed.\n\
{restage}\
_STAGED=$(git diff --cached --name-only)\n\
if [ -n \"$_STAGED\" ]; then\n\
\x20 # Pipe git's commit summary through _linthis_paint so IDE VCS\n\
\x20 # consoles don't render it red.\n\
\x20 git commit --no-verify -m \"fix(linthis): auto-fix lint issues\" 2>&1 | _linthis_paint\n\
{footer}\
fi\n",
timer = timer_fns,
fix_commit_mode = fix_commit_mode_section,
linthis = linthis_cmd,
restage = restage_scope,
footer = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Applied,
hook_type: HookTypeLabel::Git,
mode: FixCommitMode::Fixup,
event: &HookEvent::PostCommit,
header: "Created fixup commit with format changes",
indent: " ",
}),
)
}
fn shell_post_commit_apply_success(
footer_agent: &str,
footer_format: &str,
footer_conflict: &str,
save_diff: &str,
) -> String {
format!(
r#" rm -f "$_APPLY_ERR" 2>/dev/null
# Step 4: stage + commit (scoped to in-commit files)
while IFS= read -r _F; do
[ -z "$_F" ] && continue
git add -- "$_F" 2>/dev/null || true
done <<_POST_COMMIT_STAGE_EOF_
$_FILES
_POST_COMMIT_STAGE_EOF_
{save_diff}\
_STAGED=$(git diff --cached --name-only)
if [ -n "$_STAGED" ]; then
git commit --no-verify -m "fix(linthis): auto-fix lint issues" 2>&1 | _linthis_paint
if [ "$_AGENT_RAN" = "1" ]; then
{footer_agent}\
else
{footer_format}\
fi
fi
# Step 5: restore user's uncommitted state on top of the
# new HEAD via git-stash's internal 3-way merge.
if [ -n "$_USER_STASH" ]; then
if git stash apply --index "$_USER_STASH" >/dev/null 2>&1; then
_USER_RESTORED=1
else
git stash store -m "linthis: uncommitted state before fixup" "$_USER_STASH" >/dev/null 2>&1 || true
_USER_RESTORED=1
{footer_conflict}\
echo "[linthis] Recovery: 'git stash list' (find 'linthis: uncommitted')" >&2
fi
else
_USER_RESTORED=1
fi
[ -f "$_AGENT_PATCH" ] && rm -f "$_AGENT_PATCH"
trap - HUP INT TERM
"#,
save_diff = save_diff,
footer_agent = footer_agent,
footer_format = footer_format,
footer_conflict = footer_conflict,
)
}
fn shell_post_commit_apply_failure(footer_conflict: &str) -> String {
format!(
r#" # Apply failed even against clean HEAD content — patch itself
# is malformed or references missing blobs. Restore user's
# state and surface the error.
if [ -n "$_USER_STASH" ]; then
git stash apply --index "$_USER_STASH" >/dev/null 2>&1 || \
git stash store -m "linthis: post-commit recovery snapshot" "$_USER_STASH" >/dev/null 2>&1 || true
fi
_USER_RESTORED=1
trap - HUP INT TERM
{footer_conflict}\
if [ -s "$_APPLY_ERR" ]; then
echo "[linthis] git apply error:" >&2
sed 's/^/[linthis] /' "$_APPLY_ERR" >&2
fi
rm -f "$_APPLY_ERR" 2>/dev/null
echo "[linthis] Patch kept at: $_AGENT_PATCH" >&2
"#,
footer_conflict = footer_conflict,
)
}
fn shell_post_commit_apply_patch(
footer_agent: &str,
footer_format: &str,
footer_conflict: &str,
save_diff: &str,
) -> String {
let success_block =
shell_post_commit_apply_success(footer_agent, footer_format, footer_conflict, save_diff);
let failure_block = shell_post_commit_apply_failure(footer_conflict);
format!(
r#" if [ -s "$_AGENT_PATCH" ]; then
_APPLY_ERR=$(mktemp "${{TMPDIR:-/tmp}}/linthis-apply-err.XXXXXX" 2>/dev/null || mktemp)
_USER_STASH=$(git stash create 2>/dev/null || echo "")
_USER_RESTORED=0
_linthis_post_commit_cleanup() {{
if [ "$_USER_RESTORED" = "0" ] && [ -n "$_USER_STASH" ]; then
git stash apply --index "$_USER_STASH" >/dev/null 2>&1 || \
git stash store -m "linthis: post-commit recovery snapshot" "$_USER_STASH" >/dev/null 2>&1 || true
fi
}}
trap '_linthis_post_commit_cleanup' HUP INT TERM
# Step 2: reset the in-commit files to HEAD so patch base matches WT
while IFS= read -r _F; do
[ -z "$_F" ] && continue
git checkout HEAD -- "$_F" >/dev/null 2>&1 || true
done <<_POST_COMMIT_RESET_EOF_
$_FILES
_POST_COMMIT_RESET_EOF_
# Step 3: apply the auto-fix patch to now-clean WT
if git apply --binary --whitespace=nowarn "$_AGENT_PATCH" 2>"$_APPLY_ERR"; then
{success_block}\
else
{failure_block}\
fi
fi
"#,
success_block = success_block,
failure_block = failure_block,
)
}
pub(crate) fn build_post_commit_with_agent_script(
linthis_fmt_cmd: &str,
fix_provider: &AgentFixProvider,
provider_args: Option<&str>,
) -> String {
let timer_fns = shell_timer_functions();
let fix_commit_mode_section = shell_read_fix_commit_mode("pre_commit");
let prompt = agent_fix_prompt_for_post_commit();
let agent_cmd = agent_fix_headless_cmd(fix_provider, &prompt, provider_args);
let error_msg = "Lint issues remain after formatting";
let agent_check = shell_agent_availability_check(fix_provider);
let agent_block = shell_agent_invoke_block(fix_provider, &agent_cmd, error_msg, " ");
let save_diff = shell_save_diff_patch_for_post_commit(" ");
let footer_agent = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Applied,
hook_type: HookTypeLabel::GitWithAgent,
mode: FixCommitMode::Fixup,
event: &HookEvent::PostCommit,
header: "✓ Agent fix applied",
indent: " ",
});
let footer_format = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Applied,
hook_type: HookTypeLabel::GitWithAgent,
mode: FixCommitMode::Fixup,
event: &HookEvent::PostCommit,
header: "Created fixup commit with format changes",
indent: " ",
});
let footer_conflict = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Conflict,
hook_type: HookTypeLabel::GitWithAgent,
mode: FixCommitMode::Fixup,
event: &HookEvent::PostCommit,
header: "⚠ Your uncommitted changes overlap with auto-fix. Working tree has conflict markers — resolve manually.",
indent: " ",
});
let apply_patch =
shell_post_commit_apply_patch(&footer_agent, &footer_format, &footer_conflict, &save_diff);
format!(
"#!/bin/sh\n\
{timer}\
# Read fix_commit_mode (config fallback) and look for the pre-commit sentinel.\n\
{fix_commit_mode}\
_SENTINEL=\"$(git rev-parse --git-dir 2>/dev/null)/linthis/pending-fixup.json\"\n\
if [ ! -f \"$_SENTINEL\" ] && [ \"$_FIX_MODE\" != \"fixup\" ]; then\n\
\x20 exit 0\n\
fi\n\
\n\
# Get files from the commit that was just created\n\
_FILES=$(git diff-tree --no-commit-id --name-only -r HEAD)\n\
if [ -z \"$_FILES\" ]; then\n\
\x20 [ -f \"$_SENTINEL\" ] && rm -f \"$_SENTINEL\"\n\
\x20 exit 0\n\
fi\n\
# Alias for capture_agent_patch_in_wt (which reads $_PUSHED_FILES).\n\
_PUSHED_FILES=\"$_FILES\"\n\
\n\
# Physical isolation: format + agent run inside a detached worktree\n\
# at HEAD, so the user's real working tree is untouched during the\n\
# (potentially multi-minute) agent phase.\n\
{worktree_setup}\
{worktree_enter}\
\n\
# Build -i <file> args and run formatter on all committed files\n\
set --\n\
while IFS= read -r _F; do set -- \"$@\" -i \"$_F\"; done <<_POST_COMMIT_FILES_EOF_\n\
$_FILES\n\
_POST_COMMIT_FILES_EOF_\n\
_linthis_run_painted {linthis_fmt} \"$@\"\n\
_FMT_EXIT=$?\n\
\n\
# If formatter didn't fully fix, try agent (still in worktree)\n\
_AGENT_RAN=0\n\
_DIFF_FILE=\"\"\n\
if [ \"$_FMT_EXIT\" -ne 0 ]; then\n\
\x20 {agent_check}\
\x20 if [ \"$_LINTHIS_AGENT_OK\" = \"1\" ]; then\n\
{agent_block}\
\x20\x20\x20 if [ \"$_AGENT_RAN\" = \"1\" ]; then\n\
\x20\x20\x20\x20\x20 printf \"${{_LINTHIS_W}}[linthis] Re-verifying...${{_LINTHIS_R}}\\n\"\n\
\x20\x20\x20\x20\x20 _linthis_run_painted {linthis_fmt} \"$@\"\n\
\x20\x20\x20 fi\n\
\x20 fi\n\
fi\n\
\n\
# Capture format + agent changes as a patch (still inside worktree).\n\
{capture_agent_patch}\
\n\
# Leave + tear down the worktree. Patch is a tempfile in $TMPDIR,\n\
# independent of the (now deleted) worktree.\n\
{worktree_leave}\
\n\
{apply_patch}\
# Consume the sentinel regardless of outcome — no retry on failure.\n\
[ -f \"$_SENTINEL\" ] && rm -f \"$_SENTINEL\"\n",
timer = timer_fns,
fix_commit_mode = fix_commit_mode_section,
linthis_fmt = linthis_fmt_cmd,
agent_check = agent_check,
agent_block = agent_block,
apply_patch = apply_patch,
worktree_setup = shell_pre_push_worktree_setup(),
worktree_enter = shell_pre_push_worktree_enter(),
worktree_leave = shell_pre_push_worktree_leave(),
capture_agent_patch = shell_capture_agent_patch_in_wt(),
)
}
pub(crate) fn build_hook_command(hook_event: &HookEvent, args: &Option<String>) -> String {
match hook_event {
HookEvent::PreCommit => {
let extra = args.as_deref().unwrap_or("-c -f");
format!("linthis -s {} --hook-event=pre-commit", extra)
}
HookEvent::PrePush => {
let extra = args.as_deref().unwrap_or("-c");
format!("linthis {} --hook-event=pre-push", extra)
}
HookEvent::CommitMsg => {
"linthis cmsg \"$1\"".to_string()
}
HookEvent::PostCommit => {
let extra = args.as_deref().unwrap_or("-c -f");
format!("linthis {} --hook-event=post-commit", extra)
}
}
}
pub(crate) fn hook_action(hook_event: &HookEvent) -> &'static str {
match hook_event {
HookEvent::PreCommit => "commit",
HookEvent::PrePush => "push",
HookEvent::CommitMsg => "commit",
HookEvent::PostCommit => "commit",
}
}
pub(crate) const ALL_AGENT_FIX_PROVIDERS: &[AgentFixProvider] = &[
AgentFixProvider::Claude,
AgentFixProvider::Codex,
AgentFixProvider::Gemini,
AgentFixProvider::Cursor,
AgentFixProvider::Droid,
AgentFixProvider::Auggie,
AgentFixProvider::Codebuddy,
AgentFixProvider::Openclaw,
];
pub(crate) fn parse_provider_with_model(raw: &str) -> (&str, Option<&str>) {
match raw.split_once('/') {
Some((provider, model)) if !model.is_empty() => (provider, Some(model)),
_ => (raw, None),
}
}
pub(crate) fn merge_model_into_provider_args(
model: Option<&str>,
existing: Option<&str>,
) -> Option<String> {
use colored::Colorize;
if let (Some(m), Some(pa)) = (model, existing) {
if pa.contains("--model") {
eprintln!(
"{}: --provider-args already contains --model, ignoring '{}' from provider/model syntax",
"Warning".yellow(), m
);
return Some(pa.to_string());
}
}
match (model, existing) {
(Some(m), Some(pa)) => Some(format!("--model {} {}", m, pa)),
(Some(m), None) => Some(format!("--model {}", m)),
(None, Some(pa)) => Some(pa.to_string()),
(None, None) => None,
}
}
pub(crate) fn detect_agent_fix_providers() -> Vec<AgentFixProvider> {
ALL_AGENT_FIX_PROVIDERS
.iter()
.filter(|p| super::is_command_available(agent_fix_bin(p).as_ref()))
.cloned()
.collect()
}
pub(crate) fn resolve_agent_fix_provider(
provider: Option<&str>,
yes: bool,
) -> Result<AgentFixProvider, std::process::ExitCode> {
use colored::Colorize;
use std::process::ExitCode;
if let Some(p) = provider {
if let Some(known) = parse_agent_fix_provider_name(p) {
return Ok(known);
}
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let custom = linthis::config::Config::load_project_config(&cwd)
.as_ref()
.and_then(|c| c.ai.custom_providers.get(p).cloned())
.or_else(|| {
linthis::config::Config::load_user_config()
.as_ref()
.and_then(|c| c.ai.custom_providers.get(p).cloned())
});
if let Some(custom) = custom {
let bin = match &custom.command {
Some(c) => c.to_string(),
None => {
eprintln!(
"{}: custom provider '{}' requires a 'command' field in [ai.custom_providers.{}]",
"Error".red(), p, p
);
return Err(ExitCode::from(1));
}
};
let style = match &custom.cli_style {
Some(s) => s.to_string(),
None => {
eprintln!(
"{}: custom provider '{}' requires 'cli_style' in [ai.custom_providers.{}]\n Valid styles: claude, codex, gemini, cursor, droid, auggie, codebuddy, openclaw",
"Error".red(), p, p
);
return Err(ExitCode::from(1));
}
};
return Ok(AgentFixProvider::Custom { bin, style });
}
eprintln!(
"{}: Unknown provider '{}'. Add it to config:\n [ai.custom_providers.{}]\n command = \"{}\"\n cli_style = \"claude\" # or codex, gemini, cursor, droid, auggie, codebuddy, openclaw",
"Error".red(), p, p, p
);
return Err(ExitCode::from(1));
}
let detected = detect_agent_fix_providers();
if yes {
return Ok(detected
.into_iter()
.next()
.unwrap_or(AgentFixProvider::Claude));
}
use std::io::{self, Write};
println!("{}", "Select AI agent for automatic fix:".bold());
println!();
for (i, p) in ALL_AGENT_FIX_PROVIDERS.iter().enumerate() {
let available = super::is_command_available(agent_fix_bin(p).as_ref());
let tag = if available {
format!(" {}", "(detected)".cyan())
} else {
String::new()
};
println!(" {}. {}{}", i + 1, p, tag);
}
println!();
print!("Choose [1-{}]: ", ALL_AGENT_FIX_PROVIDERS.len());
io::stdout().flush().unwrap();
let mut input = String::new();
io::stdin().read_line(&mut input).ok();
let n: usize = input.trim().parse().unwrap_or(0);
if n >= 1 && n <= ALL_AGENT_FIX_PROVIDERS.len() {
Ok(ALL_AGENT_FIX_PROVIDERS[n - 1].clone())
} else {
println!("Installation cancelled");
Err(ExitCode::SUCCESS)
}
}
pub(crate) fn parse_agent_fix_provider_name(name: &str) -> Option<AgentFixProvider> {
match name.to_lowercase().as_str() {
"claude" => Some(AgentFixProvider::Claude),
"codex" => Some(AgentFixProvider::Codex),
"gemini" => Some(AgentFixProvider::Gemini),
"cursor" => Some(AgentFixProvider::Cursor),
"droid" => Some(AgentFixProvider::Droid),
"auggie" | "aug" | "augment" => Some(AgentFixProvider::Auggie),
"codebuddy" => Some(AgentFixProvider::Codebuddy),
"openclaw" => Some(AgentFixProvider::Openclaw),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn claude_headless_pipes_through_agent_stream() {
let cmd = agent_fix_headless_cmd(&AgentFixProvider::Claude, "hi", None);
assert!(cmd.contains("--verbose"), "got: {cmd}");
assert!(cmd.contains("--output-format stream-json"), "got: {cmd}");
assert!(cmd.contains("linthis agent-stream"), "got: {cmd}");
assert!(cmd.contains(" | "), "must pipe; got: {cmd}");
}
#[test]
fn codebuddy_headless_pipes_through_agent_stream() {
let cmd = agent_fix_headless_cmd(&AgentFixProvider::Codebuddy, "hi", None);
assert!(cmd.contains("--output-format stream-json"), "got: {cmd}");
assert!(cmd.contains("linthis agent-stream"), "got: {cmd}");
}
#[test]
fn commit_msg_claude_pipes_through_agent_stream() {
let cmd = agent_fix_headless_cmd_commit_msg(&AgentFixProvider::Claude, None);
assert!(cmd.contains("--output-format stream-json"), "got: {cmd}");
assert!(cmd.contains("linthis agent-stream"), "got: {cmd}");
}
#[test]
fn fix_block_has_threshold_guard_and_no_spinner() {
let block = build_agent_fix_block(&AgentFixProvider::Claude, &HookEvent::PreCommit);
assert!(block.contains("LINTHIS_AGENT_MAX_AUTO_FIX"), "got: {block}");
assert!(block.contains("streaming"), "got: {block}");
assert!(
!block.contains("start_timer \"Fixing"),
"spinner should be gone; got: {block}"
);
assert!(block.contains("linthis report count"), "got: {block}");
}
#[test]
fn fix_block_reverifies_only_when_agent_actually_ran() {
let block = build_agent_fix_block(&AgentFixProvider::Claude, &HookEvent::PreCommit);
assert!(block.contains("_AGENT_RAN"), "got: {block}");
assert!(block.contains("Re-verifying"), "got: {block}");
}
#[test]
fn threshold_parser_does_not_clobber_positional_params() {
let block = build_agent_fix_block(&AgentFixProvider::Claude, &HookEvent::PreCommit);
assert!(
!block.contains("set -- $_AGENT_COUNTS"),
"parser must not clobber positional params; got: {block}"
);
assert!(
block.contains("_AGENT_ERR=${_AGENT_COUNTS%% *}"),
"got: {block}"
);
assert!(
block.contains("_AGENT_FILES=${_AGENT_COUNTS##* }"),
"got: {block}"
);
}
#[test]
fn commit_msg_block_preserves_msg_file_through_agent_fix() {
let agent_cmd = agent_fix_headless_cmd_commit_msg(&AgentFixProvider::Claude, None);
assert!(
agent_cmd.starts_with("_REAL_MSG=\"$1\";"),
"cmsg agent command must capture $1 first; got: {agent_cmd}"
);
let outer = build_agent_fix_block(&AgentFixProvider::Claude, &HookEvent::CommitMsg);
assert!(
!outer.contains("set --"),
"no positional-param mutation allowed around cmsg agent; got: {outer}"
);
}
#[test]
fn commit_msg_uses_temp_file_to_bypass_dotgit_block() {
let cmd = agent_fix_headless_cmd_commit_msg(&AgentFixProvider::Claude, None);
assert!(
cmd.contains("mktemp"),
"must allocate temp file; got: {cmd}"
);
assert!(
cmd.contains("cp \"$_REAL_MSG\" \"$_MSG_FILE\""),
"must seed temp file with current message; got: {cmd}"
);
assert!(
cmd.contains("cp \"$_MSG_FILE\" \"$_REAL_MSG\""),
"must copy fixed message back to .git/; got: {cmd}"
);
assert!(cmd.contains("cmp -s"), "must check for changes; got: {cmd}");
assert!(
cmd.contains("rm -f \"$_MSG_FILE\""),
"must clean temp; got: {cmd}"
);
assert!(
cmd.contains("TEMP FILE outside .git/"),
"prompt must steer agent away from .git/; got: {cmd}"
);
}
#[test]
fn post_commit_script_restages_only_committed_scope() {
let s = build_post_commit_script("linthis -c -f --hook-event=post-commit");
assert!(
s.contains("while IFS= read -r _F; do"),
"restage loop missing: {s}"
);
assert!(
s.contains("git add -- \"$_F\""),
"per-file scoped add missing: {s}"
);
assert!(
s.contains("<<_RESTAGE_EOF_"),
"restage heredoc missing: {s}"
);
assert!(
!s.contains("echo \"$_CHANGED\" | xargs git add"),
"unrestricted xargs git add should be removed: {s}"
);
}
#[test]
fn post_commit_with_agent_script_runs_in_worktree_with_patch_replay() {
let s = build_post_commit_with_agent_script(
"linthis -c -f --hook-event=post-commit",
&AgentFixProvider::Claude,
None,
);
assert!(
s.contains("_PREPUSH_WT=\"\""),
"worktree setup missing from post-commit script: {s}"
);
let enter_count = s.matches("cd \"$_PREPUSH_WT\"").count();
assert!(
enter_count >= 1,
"post-commit must cd into worktree at least once (got {enter_count}): {s}"
);
assert!(
s.contains("_prepush_cleanup_wt"),
"worktree cleanup marker missing: {s}"
);
assert!(
s.contains("_PUSHED_FILES=\"$_FILES\""),
"committed-files alias for capture helper missing: {s}"
);
assert!(
s.contains("git diff --binary HEAD -- \"$@\" > \"$_AGENT_PATCH\""),
"in-worktree patch capture missing: {s}"
);
assert!(
s.contains("git apply --binary --whitespace=nowarn \"$_AGENT_PATCH\""),
"plain --binary patch replay missing: {s}"
);
assert!(
!s.contains("git apply --3way"),
"patch replay must NOT use --3way (implies --index → false positives): {s}"
);
assert!(
!s.contains("git apply --binary --index"),
"patch replay must NOT use --index: {s}"
);
assert!(
s.contains("<<_POST_COMMIT_STAGE_EOF_"),
"explicit per-file staging heredoc missing: {s}"
);
assert!(
!s.contains("<<_RESTAGE_EOF_"),
"old per-phase restage heredoc must be removed (patch replay supersedes it): {s}"
);
assert!(
!s.contains("echo \"$_FMT_CHANGED\" | xargs git add"),
"unrestricted FMT xargs git add should be removed: {s}"
);
assert!(
!s.contains("echo \"$_AGENT_CHANGED\" | xargs git add"),
"unrestricted AGENT xargs git add should be removed: {s}"
);
}
#[test]
fn pre_commit_agent_runs_in_fake_worktree_sandbox() {
use crate::cli::commands::HookEvent;
let s = build_git_with_agent_hook_script(
"linthis -s -c -f --hook-event=pre-commit",
&AgentFixProvider::Claude,
&HookEvent::PreCommit,
None,
);
assert!(
s.contains("\"${TMPDIR:-/tmp}\""),
"pre-commit sandbox base must be $TMPDIR (not ~/.linthis/): {s}"
);
assert!(
s.contains("linthis-sandbox-"),
"pre-commit sandbox must use `linthis-sandbox-<ts>-<pid>` naming: {s}"
);
assert!(
!s.contains("/.linthis/projects/") || !s.contains("fake-worktrees"),
"sandbox MUST NOT live under .linthis/ — that's where linthis's \
own exclude pattern silences everything: {s}"
);
assert!(
s.contains("-mmin +60"),
"sandbox must prune orphans older than 60 min: {s}"
);
assert!(
s.contains("trap '_sb_cleanup' EXIT HUP INT TERM"),
"sandbox must install a cleanup trap: {s}"
);
assert!(
s.contains("git show \":$_F\" > \"$_SANDBOX/$_F\""),
"staged content must be materialized via `git show :<file>`: {s}"
);
assert!(
s.contains(
"git init -q && git -c user.email=linthis@local -c user.name=linthis add -A"
),
"sandbox must init + stage files (no commit): {s}"
);
assert!(
!s.contains("commit -q --no-verify -m \"baseline\""),
"sandbox must NOT commit — that hides files from `linthis -s`: {s}"
);
assert!(
s.contains("git diff --binary ) > \"$_SB_PATCH\"")
|| s.contains("git diff --binary) > \"$_SB_PATCH\""),
"sandbox must capture via `git diff --binary` (WT vs index, no HEAD): {s}"
);
assert!(
!s.contains("git diff --binary HEAD"),
"sandbox capture must NOT reference HEAD (no baseline commit): {s}"
);
assert!(
s.contains("git apply --binary --whitespace=nowarn \"$_SB_PATCH\""),
"sandbox patch must replay with plain --binary to real root: {s}"
);
assert!(
!s.contains("git apply --3way"),
"sandbox patch replay must NOT use --3way (implies --index): {s}"
);
assert!(
s.contains("Couldn't set up sandbox"),
"pre-commit agent must print a clearly-labeled fallback message: {s}"
);
}
#[test]
fn pre_commit_agent_runs_before_mode_specific_handling() {
use crate::cli::commands::HookEvent;
let s = build_git_with_agent_hook_script(
"linthis -s -c -f --hook-event=pre-commit",
&AgentFixProvider::Claude,
&HookEvent::PreCommit,
None,
);
let agent_idx = s
.find("_SB_REAL_ROOT=")
.expect("sandbox setup marker missing");
let dirty_branch_idx = s
.find("elif [ \"$_FIX_MODE\" = \"dirty\" ]")
.expect("dirty branch missing");
assert!(
agent_idx < dirty_branch_idx,
"agent invocation must precede mode-specific handling \
(got agent@{agent_idx}, dirty_branch@{dirty_branch_idx}): {s}"
);
let dirty_exit_idx = s[dirty_branch_idx..]
.find("exit 1")
.map(|i| i + dirty_branch_idx)
.expect("dirty branch exit 1 missing");
assert!(
agent_idx < dirty_exit_idx,
"dirty branch's exit 1 must follow agent invocation \
(got agent@{agent_idx}, dirty_exit@{dirty_exit_idx}): {s}"
);
}
#[test]
fn pre_commit_fixup_writes_post_commit_sentinel() {
use crate::cli::commands::HookEvent;
let pre_commit_agent = build_git_with_agent_hook_script(
"linthis -s -c -f --hook-event=pre-commit",
&AgentFixProvider::Claude,
&HookEvent::PreCommit,
None,
);
assert!(
pre_commit_agent.contains("pending-fixup.json"),
"pre-commit (git-with-agent) fixup branch must write sentinel: {pre_commit_agent}"
);
let fixup_branch_start = pre_commit_agent
.find("if [ \"$_FIX_MODE\" = \"fixup\" ]; then")
.expect("fixup branch missing");
let after_fixup = &pre_commit_agent[fixup_branch_start..];
assert!(
after_fixup.contains("pending-fixup.json"),
"sentinel write must appear within the fixup branch: {after_fixup}"
);
let pre_commit_git = build_global_hook_script_for_event(&HookEvent::PreCommit, &None, None);
assert!(
pre_commit_git.contains("pending-fixup.json"),
"pre-commit (git type) fixup branch must write sentinel: {pre_commit_git}"
);
assert!(
pre_commit_git.contains("elif [ \"$_FIX_MODE\" = \"fixup\" ]"),
"git-type pre-commit fixup gate missing: {pre_commit_git}"
);
}
#[test]
fn post_commit_with_agent_script_gates_on_sentinel_or_fix_mode() {
let s = build_post_commit_with_agent_script(
"linthis -c -f --hook-event=post-commit",
&AgentFixProvider::Claude,
None,
);
assert!(
s.contains(
"_SENTINEL=\"$(git rev-parse --git-dir 2>/dev/null)/linthis/pending-fixup.json\""
),
"sentinel path resolution missing: {s}"
);
assert!(
s.contains("if [ ! -f \"$_SENTINEL\" ] && [ \"$_FIX_MODE\" != \"fixup\" ]; then"),
"sentinel-or-fix_mode activation gate missing: {s}"
);
assert!(
s.contains("[ -f \"$_SENTINEL\" ] && rm -f \"$_SENTINEL\""),
"sentinel consumption on exit missing: {s}"
);
}
#[test]
fn restage_heredoc_terminator_is_at_column_zero() {
for indent in ["", " ", " "] {
let snippet = shell_restage_committed_scope(indent);
for marker in ["$_FILES", "_RESTAGE_EOF_"] {
let expected = format!("\n{marker}\n");
assert!(
snippet.contains(&expected),
"`{marker}` must be on its own line with no leading whitespace \
(indent={indent:?}); got:\n{snippet}"
);
}
}
}
#[test]
fn post_commit_informational_output_is_stdout() {
let plain = build_post_commit_script("linthis -c -f --hook-event=post-commit");
let agent = build_post_commit_with_agent_script(
"linthis -c -f --hook-event=post-commit",
&AgentFixProvider::Claude,
None,
);
for (name, script) in [("plain", &plain), ("agent", &agent)] {
for offender in [
"Created fixup commit with format changes\" >&2",
"Agent fix applied\\n\" >&2",
"switch mode → \\033[0;36mlinthis config set hook.pre_commit.fix_commit_mode",
] {
if offender.starts_with("switch mode") {
let stderr_form =
format!("{offender} [dirty|squash|fixup] -g\\033[0m\\n\" >&2");
assert!(
!script.contains(&stderr_form),
"{name}: fix-commit-mode tip must not end with >&2"
);
} else {
assert!(
!script.contains(offender),
"{name}: `{offender}` found — informational output must not be stderr"
);
}
}
assert!(
script.contains(
"git commit --no-verify -m \"fix(linthis): auto-fix lint issues\" 2>&1 | _linthis_paint"
),
"{name}: git commit summary must be merged + painted: {script}"
);
}
assert!(
!agent.contains("echo \"[linthis] Re-verifying...\" >&2"),
"Re-verifying must be stdout"
);
assert!(
!agent
.contains("[linthis] Undo (by patch created) : \\033[0;33mgit apply -R $_DIFF_FILE\\033[0m\\n\" >&2"),
"Undo-by-patch hint must be stdout"
);
for (name, script) in [("plain", &plain), ("agent", &agent)] {
let linthis_invocations = script.lines().filter(|l| {
let trimmed = l.trim_start();
(trimmed.contains("linthis ")
|| trimmed.contains("$LINTHIS_CMD")
|| trimmed.contains("hook-event=post-commit"))
&& !trimmed.starts_with("#")
&& !trimmed.starts_with("LINTHIS_CMD=")
&& trimmed.contains("\"$@\"")
});
for line in linthis_invocations {
let ok = line.contains("_linthis_run_painted") || line.contains("2>&1");
assert!(
ok,
"{name}: linthis invocation missing _linthis_run_painted/2>&1 (would leak red stderr to IDE):\n {line}\n---script---\n{script}"
);
}
}
}
#[test]
#[ignore = "diagnostic dump — run with: cargo test --release --bin linthis -- --ignored dump_pre_commit"]
fn dump_pre_commit() {
use crate::cli::commands::HookEvent;
let linthis_cmd = super::build_hook_command(&HookEvent::PreCommit, &None);
let script = build_git_with_agent_hook_script(
&linthis_cmd,
&AgentFixProvider::Codebuddy,
&HookEvent::PreCommit,
Some("--model glm-5.0-ioa"),
);
println!("---BEGIN PRE-COMMIT SCRIPT---");
println!("{script}");
println!("---END PRE-COMMIT SCRIPT---");
}
#[test]
#[ignore = "diagnostic dump — run with: cargo test --release --bin linthis -- --ignored dump_pre_push"]
fn dump_pre_push() {
let script = build_git_with_agent_prepush_script(
"linthis -c --hook-event=pre-push",
&AgentFixProvider::Codebuddy,
None,
);
println!("---BEGIN PRE-PUSH SCRIPT---");
println!("{script}");
println!("---END PRE-PUSH SCRIPT---");
}
#[test]
#[ignore = "diagnostic dump — run with: cargo test --release --bin linthis -- --ignored dump_post_commit_agent"]
fn dump_post_commit_agent() {
let script = build_post_commit_with_agent_script(
"linthis -c -f --hook-event=post-commit",
&AgentFixProvider::Codebuddy,
None,
);
println!("---BEGIN POST-COMMIT-AGENT SCRIPT---");
println!("{script}");
println!("---END POST-COMMIT-AGENT SCRIPT---");
}
#[test]
fn ide_color_wrapping_toggles_by_env() {
use std::io::Write;
use std::process::{Command, Stdio};
let script = format!(
"{}\nprintf \"${{_LINTHIS_W}}[linthis] hello${{_LINTHIS_R}}\\n\"\n\
echo '[plain]' | _linthis_paint\n",
shell_timer_functions()
);
let run = |env_value: Option<&str>| -> String {
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(&script)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env_remove("CI")
.env_remove("GITHUB_ACTIONS")
.env_remove("GITLAB_CI")
.env_remove("CIRCLECI")
.env_remove("BUILDKITE")
.env_remove("CONTINUOUS_INTEGRATION");
match env_value {
Some(v) => {
cmd.env("LINTHIS_HOOK_COLOR", v);
}
None => {
cmd.env_remove("LINTHIS_HOOK_COLOR");
}
}
let out = cmd.output().expect("spawn sh");
assert!(out.status.success(), "sh failed: {:?}", out);
String::from_utf8_lossy(&out.stdout).into_owned()
};
let forced = run(Some("white"));
assert!(
forced.contains("\x1b[0;37m[linthis] hello\x1b[0m"),
"white must wrap printf; got: {forced:?}"
);
assert!(
forced.contains("\x1b[0;37m[plain]\x1b[0m"),
"white must wrap _linthis_paint input; got: {forced:?}"
);
let off = run(Some("off"));
assert!(
!off.contains("\x1b[0;37m"),
"off must not emit white ANSI; got: {off:?}"
);
assert!(
off.contains("[linthis] hello"),
"off must emit the line text; got: {off:?}"
);
assert!(
off.contains("[plain]\n"),
"off must pass _linthis_paint through cat; got: {off:?}"
);
let paint_line = off.lines().find(|l| l.contains("[plain]")).unwrap();
assert!(
!paint_line.contains("\x1b["),
"off's _linthis_paint must not add ANSI; got: {paint_line:?}"
);
}
#[test]
fn git_type_global_hook_defines_paint_helpers() {
use crate::cli::commands::HookEvent;
for event in [HookEvent::PreCommit, HookEvent::PrePush] {
let script = build_global_hook_script_for_event(&event, &None, None);
assert!(
script.contains("_linthis_run_painted()"),
"{event:?}: _linthis_run_painted definition missing — \
pre-push without agent would break: {script}"
);
assert!(
script.contains("_linthis_paint()"),
"{event:?}: _linthis_paint definition missing: {script}"
);
assert!(
script.contains("_linthis_run_painted $LINTHIS_CMD"),
"{event:?}: call site not wired to helper: {script}"
);
}
}
#[test]
fn generated_hook_scripts_pass_sh_parse_check() {
use std::io::Write;
use std::process::{Command, Stdio};
let scripts = [
(
"post_commit_script",
build_post_commit_script("linthis -c -f --hook-event=post-commit"),
),
(
"post_commit_with_agent_script",
build_post_commit_with_agent_script(
"linthis -c -f --hook-event=post-commit",
&AgentFixProvider::Claude,
None,
),
),
(
"pre_push_with_agent_script",
build_git_with_agent_prepush_script(
"linthis -c --hook-event=pre-push",
&AgentFixProvider::Codebuddy,
None,
),
),
];
for (name, body) in scripts {
let mut child = Command::new("sh")
.arg("-n")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn sh -n");
child
.stdin
.as_mut()
.expect("stdin")
.write_all(body.as_bytes())
.expect("write script");
let out = child.wait_with_output().expect("wait sh -n");
assert!(
out.status.success(),
"`sh -n` rejected generated {name}:\nstderr: {}\n---script---\n{}",
String::from_utf8_lossy(&out.stderr),
body,
);
}
}
#[test]
fn pre_push_handler_stages_only_pushed_scope() {
let s = build_git_with_agent_prepush_script(
"linthis -c --hook-event=pre-push",
&AgentFixProvider::Codebuddy,
None,
);
assert!(
s.contains("$_PUSHED_FILES"),
"handler must reference $_PUSHED_FILES for scoping: {s}"
);
assert!(
s.contains("git add -- \"$_F\""),
"per-file scoped add missing: {s}"
);
assert!(
!s.contains("echo \"$_CHANGED\" | xargs git add"),
"unrestricted $_CHANGED xargs git add must be removed: {s}"
);
assert!(
!s.contains("echo \"$_AGENT_CHANGED\" | xargs git add"),
"unrestricted $_AGENT_CHANGED xargs git add must be removed: {s}"
);
}
#[test]
fn pre_push_lint_fix_uses_precise_isolation() {
let s = build_git_with_agent_prepush_script(
"linthis -c --hook-event=pre-push",
&AgentFixProvider::Codebuddy,
None,
);
assert!(
s.contains("_USER_STASH=$(git stash create"),
"stash snapshot missing: {s}"
);
assert!(
s.contains("git checkout HEAD -- \"$_F\""),
"reset-to-HEAD missing: {s}"
);
assert!(
s.contains("git diff --binary -- \"$@\" > \"$_LINTHIS_PATCH\""),
"format patch capture missing: {s}"
);
assert!(
s.contains("git stash apply --index \"$_USER_STASH\""),
"stash restore via --index missing: {s}"
);
assert!(
s.contains("trap '_linthis_precise_cleanup'"),
"safety trap missing: {s}"
);
assert!(
s.contains("git stash store -m \"linthis:"),
"stash-store fallback missing: {s}"
);
let dirty_branch = s
.split("[ \"$_FIX_MODE\" = \"dirty\" ]")
.nth(1)
.and_then(|after| after.split("[ \"$_FIX_MODE\" =").next())
.unwrap_or("");
assert!(
dirty_branch.contains("exit $LINTHIS_EXIT"),
"dirty branch must exit immediately: {dirty_branch}"
);
assert!(
!dirty_branch.contains("git stash create"),
"dirty branch must not run precise flow: {dirty_branch}"
);
assert!(
!dirty_branch.contains("git commit"),
"dirty branch must not commit: {dirty_branch}"
);
}
#[test]
fn pushed_scope_heredoc_terminators_at_column_zero() {
let s = build_git_with_agent_prepush_script(
"linthis -c --hook-event=pre-push",
&AgentFixProvider::Codebuddy,
None,
);
for marker in [
"_PRECISE_RESET_EOF_",
"_PRECISE_LIST_EOF_",
"_PRECISE_ADD_EOF_",
"_AGENT_PATCH_SCOPE_EOF_",
] {
let expected = format!("\n{marker}\n");
assert!(
s.contains(&expected),
"`{marker}` must be on its own line with no leading whitespace; \
got script:\n{s}"
);
}
}
#[test]
fn pre_push_agent_review_runs_in_worktree_with_patch_replay() {
let s = build_git_with_agent_prepush_script(
"linthis -c --hook-event=pre-push",
&AgentFixProvider::Codebuddy,
None,
);
let setup_idx = s
.find("_PREPUSH_WT=\"\"")
.expect("worktree setup marker missing");
let agent_invoke_idx = s.find("Invoking").expect("agent invocation line missing");
assert!(
setup_idx < agent_invoke_idx,
"worktree setup must appear BEFORE agent invocation (got setup@{setup_idx}, \
invoke@{agent_invoke_idx}): {s}"
);
let enter_count = s.matches("cd \"$_PREPUSH_WT\"").count();
assert!(
enter_count >= 2,
"agent phase must re-enter the worktree (expected ≥ 2 cd into \
$_PREPUSH_WT, got {enter_count}): {s}"
);
assert!(
!s.contains("_PRE_AGENT_STASH="),
"in-place pre-agent stash must be removed (worktree replaces it): {s}"
);
assert!(
!s.contains("_linthis_agent_cleanup"),
"in-place pre-agent cleanup trap must be removed: {s}"
);
let capture_idx = s
.find("git diff --binary HEAD -- \"$@\" > \"$_AGENT_PATCH\"")
.expect("in-worktree agent patch capture missing");
let leave_idx = s
.rfind("_prepush_cleanup_wt")
.expect("worktree cleanup marker missing");
assert!(
agent_invoke_idx < capture_idx && capture_idx < leave_idx,
"ordering must be: agent invoke → capture patch → worktree leave \
(got invoke@{agent_invoke_idx}, capture@{capture_idx}, leave@{leave_idx}): {s}"
);
assert!(
s.contains("git apply --binary --whitespace=nowarn \"$_AGENT_PATCH\""),
"plain --binary patch apply missing: {s}"
);
assert!(
!s.contains("git apply --3way"),
"apply must NOT use --3way (implies --index → false positives): {s}"
);
assert!(
!s.contains("git apply --binary --index"),
"apply must NOT use --index: {s}"
);
assert!(
s.contains("<<_APPLY_STAGE_EOF_"),
"squash/fixup must stage via explicit `git add` heredoc: {s}"
);
assert!(
s.contains("git reset --soft HEAD~2"),
"squash fold dance missing after patch apply: {s}"
);
}
#[test]
fn pre_push_runs_agent_fix_then_relint_before_mode_handler() {
let s = build_git_with_agent_prepush_script(
"linthis -c --hook-event=pre-push",
&AgentFixProvider::Codebuddy,
None,
);
assert!(
s.contains("[ \"$LINTHIS_EXIT\" -ne 0 ] && [ \"$_LINTHIS_AGENT_OK\" = \"1\" ]"),
"agent fix block must be gated on LINTHIS_EXIT and _LINTHIS_AGENT_OK: {s}"
);
assert!(
s.contains("_AGENT_FIX_ATTEMPTED=1"),
"fix block must set _AGENT_FIX_ATTEMPTED=1: {s}"
);
let relint_count = s.matches("_LINT_OUT=$(").count();
assert!(
relint_count >= 2,
"must re-run lint after agent fix (expected ≥ 2 captures of \
_LINT_OUT, got {relint_count}): {s}"
);
let fix_idx = s
.find("Lint issues were found")
.expect("fix-prompt anchor missing");
let review_idx = s
.find("structured pre-push code review")
.expect("review-prompt anchor missing");
assert!(
fix_idx < review_idx,
"fix prompt must appear BEFORE review prompt (fix@{fix_idx}, \
review@{review_idx}): {s}"
);
let fix_block_idx = s
.find("_AGENT_FIX_ATTEMPTED=1")
.expect("fix block marker missing");
let dirty_handler_idx = s
.find("Handle fix_commit_mode for pre-push")
.expect("mode handler marker missing");
assert!(
fix_block_idx < dirty_handler_idx,
"agent fix block must appear BEFORE mode handler (fix@{fix_block_idx}, \
handler@{dirty_handler_idx}): {s}"
);
}
#[test]
fn squash_apply_writes_sentinel_for_fastpath() {
let s = build_git_with_agent_prepush_script(
"linthis -c --hook-event=pre-push",
&AgentFixProvider::Codebuddy,
None,
);
assert!(
s.contains("_SENTINEL_SHA=$(git rev-parse HEAD"),
"squash apply success path must read post-commit HEAD SHA: {s}"
);
assert!(
s.contains("$_SENTINEL_DIR/pre-push-sentinel"),
"sentinel must be written under ~/.linthis/projects/<slug>/pre-push-sentinel: {s}"
);
assert!(
s.contains("\"$HOME/.linthis/projects/$_PREPUSH_STUB\""),
"sentinel dir must derive from $_PREPUSH_STUB (real slug), \
not git-rev-parse from inside worktree: {s}"
);
let sentinel_idx = s
.find("printf '%s\\n' \"$_SENTINEL_SHA\"")
.expect("sentinel printf marker missing");
let success_marker = "Agent fixes squashed into latest commit";
let success_idx = s
.find(success_marker)
.expect("squash success header missing");
assert!(
success_idx < sentinel_idx,
"sentinel write must come AFTER success footer (success@{success_idx}, \
sentinel@{sentinel_idx})"
);
}
#[test]
fn pre_push_fastpath_skips_when_sentinel_matches_head() {
let s = build_git_with_agent_prepush_script(
"linthis -c --hook-event=pre-push",
&AgentFixProvider::Codebuddy,
None,
);
assert!(
s.contains("_FAST_SENTINEL=\"$HOME/.linthis/projects/$_FAST_STUB/pre-push-sentinel\""),
"fast-path must read $HOME/.linthis/projects/<slug>/pre-push-sentinel: {s}"
);
assert!(
s.contains("[ \"$_FAST_SAVED_SHA\" = \"$_FAST_HEAD_SHA\" ]"),
"fast-path must compare sentinel SHA to current HEAD SHA: {s}"
);
let rm_count = s.matches("rm -f \"$_FAST_SENTINEL\"").count();
assert!(
rm_count >= 2,
"fast-path must delete sentinel both on match (consume) and on \
mismatch (stale) — found {rm_count} rm sites: {s}"
);
let fast_idx = s.find("Fast-path: HEAD").expect("fast-path marker missing");
let lint_idx = s
.find("_LINT_OUT=$(linthis")
.expect("lint check marker missing");
assert!(
fast_idx < lint_idx,
"fast-path must be evaluated BEFORE lint check (fast@{fast_idx}, \
lint@{lint_idx})"
);
}
#[test]
fn squash_apply_success_must_exit_1_so_pushed_sha_matches_squashed_head() {
let s = build_git_with_agent_prepush_script(
"linthis -c --hook-event=pre-push",
&AgentFixProvider::Codebuddy,
None,
);
let success_marker = "Agent fixes squashed into latest commit";
let success_idx = s
.find(success_marker)
.expect("squash success footer missing");
let after_success = &s[success_idx..];
let exit_one_idx = after_success.find("exit 1\n");
let exit_zero_idx = after_success.find("exit 0\n");
let exit_one_first = match (exit_one_idx, exit_zero_idx) {
(Some(o), Some(z)) => o < z,
(Some(_), None) => true,
_ => false,
};
assert!(
exit_one_first,
"squash success path MUST exit 1 (git pushes pre-hook SHA; \
exit 0 ships the unfixed commit). \
exit_one_idx={exit_one_idx:?}, exit_zero_idx={exit_zero_idx:?}"
);
}
#[test]
fn squash_apply_isolates_agent_patch_from_user_wip() {
let s = build_git_with_agent_prepush_script(
"linthis -c --hook-event=pre-push",
&AgentFixProvider::Codebuddy,
None,
);
assert!(
s.contains("_USER_STASH=$(git stash create"),
"squash apply must snapshot user state via `git stash create`: {s}"
);
assert!(
s.contains("_APPLY_RESET_EOF_"),
"squash apply must use the reset-to-HEAD heredoc: {s}"
);
assert!(
s.contains("trap '_apply_cleanup_stash' HUP INT TERM"),
"squash apply must trap signals to recover stash: {s}"
);
assert!(
s.contains("git stash apply --index \"$_USER_STASH\""),
"squash apply must restore user state via 3-way merge: {s}"
);
assert!(
s.contains("git stash store -m \"linthis: uncommitted state before pre-push squash\""),
"stash-restore conflict path must store stash for recovery: {s}"
);
let reset_idx = s
.find("_APPLY_RESET_EOF_")
.expect("reset heredoc marker missing");
let stage_idx = s
.find("_APPLY_STAGE_EOF_")
.expect("stage heredoc marker missing");
assert!(
reset_idx < stage_idx,
"reset-to-HEAD must come BEFORE staging (got reset@{reset_idx}, stage@{stage_idx}): {s}"
);
}
#[test]
fn pre_push_prompts_forbid_agent_committing_in_worktree() {
let fix_prompt = super::agent_fix_prompt_for_event(&HookEvent::PrePush);
let review_prompt = super::prepush_review_prompt();
for (label, prompt) in [
("fix prompt", fix_prompt.as_str()),
("review prompt", review_prompt),
] {
assert!(
prompt.contains("DO NOT run `git commit`"),
"{label} must explicitly forbid `git commit`: {prompt}"
);
assert!(
prompt.contains("UNCOMMITTED edits in the working tree"),
"{label} must instruct agent to leave changes uncommitted: {prompt}"
);
}
}
#[test]
fn prepush_review_prompt_auto_fixes_important_and_critical() {
let prompt = super::prepush_review_prompt();
assert!(
prompt.contains("Critical OR Important"),
"review prompt must auto-fix Critical OR Important issues: {prompt}"
);
assert!(
prompt.contains("Minor issues are informational") && prompt.contains("DO NOT auto-fix"),
"review prompt must keep Minor issues informational only: {prompt}"
);
assert!(
prompt.contains("Push blocked — fix Critical issues first"),
"push-block gate must still be Critical-only: {prompt}"
);
}
#[test]
fn pre_push_exports_review_dir_from_real_project_slug() {
let s = build_git_with_agent_prepush_script(
"linthis -c --hook-event=pre-push",
&AgentFixProvider::Codebuddy,
None,
);
assert!(
s.contains(
"_LINTHIS_REVIEW_DIR=\"$HOME/.linthis/projects/$_PREPUSH_STUB/review/result\""
),
"review dir must be derived from $_PREPUSH_STUB (real project slug): {s}"
);
assert!(
s.contains("export _LINTHIS_REVIEW_DIR"),
"_LINTHIS_REVIEW_DIR must be exported for agent: {s}"
);
let review_prompt = super::prepush_review_prompt();
assert!(
review_prompt.contains("$_LINTHIS_REVIEW_DIR"),
"review prompt must reference $_LINTHIS_REVIEW_DIR: {review_prompt}"
);
assert!(
!review_prompt.contains("_SLUG=$(git rev-parse --show-toplevel"),
"review prompt must NOT recompute slug from git toplevel \
(returns wt path inside the hook): {review_prompt}"
);
assert!(
s.contains("${_LINTHIS_REVIEW_DIR:-/dev/null}"),
"review report check must read $_LINTHIS_REVIEW_DIR: {s}"
);
}
#[test]
fn pre_push_fix_prompt_uses_report_show_not_staged_check() {
let prompt = super::agent_fix_prompt_for_event(&HookEvent::PrePush);
assert!(
prompt.contains("linthis report show"),
"pre-push fix prompt must instruct `linthis report show`: {prompt}"
);
for forbidden in [
"Run 'linthis -s'",
"Run `linthis -s`",
"Re-run 'linthis -s'",
"Re-run `linthis -s`",
] {
assert!(
!prompt.contains(forbidden),
"pre-push fix prompt must NOT instruct {forbidden:?} (returns \
empty in pre-push because nothing is staged): {prompt}"
);
}
let pre_commit_prompt = super::agent_fix_prompt_for_event(&HookEvent::PreCommit);
assert!(
pre_commit_prompt.contains("linthis -s"),
"pre-commit prompt should still use `linthis -s` (staged files): \
{pre_commit_prompt}"
);
}
#[test]
fn pre_push_dirty_mode_blocks_after_agent_fix_attempt() {
let s = build_git_with_agent_prepush_script(
"linthis -c --hook-event=pre-push",
&AgentFixProvider::Codebuddy,
None,
);
assert!(
s.contains(
"[ \"$_FIX_MODE\" = \"dirty\" ] && [ \"${_AGENT_FIX_ATTEMPTED:-0}\" = \"1\" ]"
),
"dirty post-fix block must gate on $_FIX_MODE and $_AGENT_FIX_ATTEMPTED: {s}"
);
let apply_idx = s
.rfind("git apply --binary --whitespace=nowarn \"$_AGENT_PATCH\"")
.expect("patch apply marker missing");
let dirty_block_idx = s
.find("[ \"$_FIX_MODE\" = \"dirty\" ] && [ \"${_AGENT_FIX_ATTEMPTED:-0}\" = \"1\" ]")
.expect("dirty post-fix marker missing");
assert!(
apply_idx < dirty_block_idx,
"dirty block must come AFTER patch apply (apply@{apply_idx}, \
dirty_block@{dirty_block_idx}): {s}"
);
let after_block = &s[dirty_block_idx..];
let exit1_idx = after_block
.find("exit 1")
.expect("exit 1 missing in dirty block");
let next_fi_idx = after_block.find("\nfi").expect("closing fi missing");
assert!(
exit1_idx < next_fi_idx,
"dirty block must `exit 1` BEFORE its closing `fi`: {after_block}"
);
}
#[test]
fn dirty_mode_branches_emit_mode_switch_tip() {
use crate::cli::commands::HookEvent;
let prepush = build_git_with_agent_prepush_script(
"linthis -c --hook-event=pre-push",
&AgentFixProvider::Codebuddy,
None,
);
let occurrences = prepush
.matches("linthis config set hook.pre_push.fix_commit_mode <mode> -g")
.count();
assert!(
occurrences >= 6,
"pre-push script must include the unified pre_push switch block in all \
final-outcome branches (expected >= 6 occurrences, got {occurrences}): {prepush}"
);
let pre_commit_agent = build_git_with_agent_hook_script(
"linthis -s -c -f --hook-event=pre-commit",
&AgentFixProvider::Claude,
&HookEvent::PreCommit,
None,
);
assert!(
pre_commit_agent
.contains("linthis config set hook.pre_commit.fix_commit_mode <mode> -g"),
"pre-commit (git-with-agent) must emit the unified pre_commit switch block: \
{pre_commit_agent}"
);
let pre_commit_git = build_global_hook_script_for_event(&HookEvent::PreCommit, &None, None);
assert!(
pre_commit_git.contains("linthis config set hook.pre_commit.fix_commit_mode <mode> -g"),
"pre-commit (git type) must emit the unified pre_commit switch block: \
{pre_commit_git}"
);
}
#[test]
fn squash_mode_branches_emit_message_and_tip() {
use crate::cli::commands::HookEvent;
let pre_commit_git = build_global_hook_script_for_event(&HookEvent::PreCommit, &None, None);
assert!(
pre_commit_git
.contains("Format fixes folded into your commit (squash mode — single commit)"),
"pre-commit (git type) squash SUCCESS header missing: {pre_commit_git}"
);
assert!(
pre_commit_git.contains(
"Format fixes staged — will fold into your next 'git commit' (squash mode)"
),
"pre-commit (git type) squash FAILURE progress echo missing: {pre_commit_git}"
);
assert!(
pre_commit_git.contains("✗ Unfixable lint issues above — THIS commit blocked"),
"pre-commit (git type) squash FAILURE unified header missing: {pre_commit_git}"
);
assert!(
pre_commit_git.contains("Currently: --type git"),
"pre-commit (git type) blocked footer must show current --type: {pre_commit_git}"
);
assert!(
pre_commit_git.contains("Want auto-fix + AI code review? Switch hook type"),
"pre-commit (git type) blocked footer must suggest git-with-agent: {pre_commit_git}"
);
assert!(
pre_commit_git.contains("linthis hook install -g --type git-with-agent")
&& pre_commit_git.contains("--event pre-commit --force"),
"pre-commit (git type) blocked footer must include install command with event: \
{pre_commit_git}"
);
for per_mode_line in [
"dirty (d) = format left unstaged",
"squash (s) = format auto-staged into your commit — one commit (current)",
"fixup (f) = commit proceeds; a separate auto-fix commit is created post-commit",
] {
assert!(
pre_commit_git.contains(per_mode_line),
"pre-commit (git type) footer must include {per_mode_line:?}: {pre_commit_git}"
);
}
let switch_block_count = pre_commit_git
.matches("linthis config set hook.pre_commit.fix_commit_mode <mode> -g")
.count();
assert!(
switch_block_count >= 2,
"pre-commit (git type) must emit ≥ 2 unified switch blocks \
(got {switch_block_count}): {pre_commit_git}"
);
let pre_commit_agent = build_git_with_agent_hook_script(
"linthis -s -c -f --hook-event=pre-commit",
&AgentFixProvider::Claude,
&HookEvent::PreCommit,
None,
);
assert!(
pre_commit_agent
.contains("Format fixes folded into your commit (squash mode — single commit)"),
"pre-commit (git-with-agent) squash SUCCESS header missing: {pre_commit_agent}"
);
assert!(
pre_commit_agent.contains(
"Format fixes staged — will fold into your next 'git commit' (squash mode)"
),
"pre-commit (git-with-agent) squash FAILURE progress echo missing: {pre_commit_agent}"
);
assert!(
pre_commit_agent.contains(
"squash (s) = format auto-staged into your commit — one commit (current)"
),
"pre-commit (git-with-agent) blocked footer must mark squash (current): \
{pre_commit_agent}"
);
assert!(
!pre_commit_agent.contains("Want auto-fix + AI code review"),
"pre-commit (git-with-agent) must NOT suggest upgrading (already agent): \
{pre_commit_agent}"
);
assert!(
!pre_commit_agent.contains("Currently: --type git"),
"pre-commit (git-with-agent) blocked footer must not emit the Currently line: \
{pre_commit_agent}"
);
}
#[test]
fn pre_push_uses_tree_delta_not_commit_union() {
use crate::cli::commands::HookEvent;
let git_script = build_global_hook_script_for_event(&HookEvent::PrePush, &None, None);
assert!(
git_script.contains("git diff --name-only \"$_BASE\"..\"$_LOCAL_SHA\""),
"--type git pre-push must use `git diff --name-only` for tree-delta: {git_script}"
);
assert!(
!git_script.contains("git log --name-only --pretty=format:"),
"--type git pre-push must NOT use `git log --name-only` (that would force \
re-linting files with zero net change): {git_script}"
);
let agent_script = build_git_with_agent_prepush_script(
"linthis -c --hook-event=pre-push",
&AgentFixProvider::Codebuddy,
None,
);
assert!(
agent_script.contains("git diff --name-only \"$_BASE\"..HEAD"),
"--type git-with-agent pre-push must use `git diff --name-only` for tree-delta: \
{agent_script}"
);
assert!(
!agent_script.contains("git log --name-only --pretty=format:"),
"--type git-with-agent pre-push must NOT use `git log --name-only`: {agent_script}"
);
}
#[test]
fn git_type_pre_push_is_visible() {
use crate::cli::commands::HookEvent;
let s = build_global_hook_script_for_event(&HookEvent::PrePush, &None, None);
assert!(
s.contains("Pre-push check: verifying"),
"pre-push must announce how many files it will check: {s}"
);
assert!(
s.contains("Tag push detected"),
"pre-push must announce tag-push skip: {s}"
);
assert!(
s.contains("No source files changed"),
"pre-push must announce empty-diff skip: {s}"
);
assert!(
s.contains("Remote SHA not fetched locally"),
"pre-push must hint when falling back to upstream base: {s}"
);
assert!(
s.contains("rev-parse '@{u}'"),
"pre-push fallback must try @{{u}}: {s}"
);
assert!(
s.contains("Pre-push check passed"),
"pre-push must announce success: {s}"
);
assert!(
s.contains("Pre-push check failed"),
"pre-push must announce failure: {s}"
);
assert!(
s.contains("Currently: --type git"),
"pre-push failure footer must show current --type: {s}"
);
assert!(
s.contains("Want auto-fix + AI code review") && s.contains("--type git-with-agent"),
"pre-push failure footer must suggest git-with-agent: {s}"
);
}
fn footer(
outcome: FooterOutcome,
hook_type: HookTypeLabel,
mode: FixCommitMode,
event: &HookEvent,
header: &str,
) -> String {
shell_hook_footer(&FooterCtx {
outcome,
hook_type,
mode,
event,
header,
indent: "",
})
}
#[test]
fn footer_header_always_first_line_printf_stdout() {
let s = footer(
FooterOutcome::Applied,
HookTypeLabel::GitWithAgent,
FixCommitMode::Fixup,
&HookEvent::PreCommit,
"✓ Agent fix applied",
);
let first_line = s.lines().next().expect("at least one line");
assert!(
first_line.contains("printf")
&& first_line.contains("${_LINTHIS_W}")
&& first_line.contains("${_LINTHIS_R}"),
"header must be white-wrapped printf: {first_line}"
);
assert!(
first_line.contains("[linthis] ✓ Agent fix applied"),
"header text must appear verbatim: {first_line}"
);
for line in s.lines() {
assert!(
!line.contains(">&2"),
"footer must not write to stderr: {line}"
);
}
}
#[test]
fn footer_applied_emits_view_undo_and_patch_gate() {
let s = footer(
FooterOutcome::Applied,
HookTypeLabel::GitWithAgent,
FixCommitMode::Fixup,
&HookEvent::PrePush,
"Created fixup commit with agent fixes",
);
assert!(s.contains("View changes : \\033[0;36mgit diff HEAD~1"));
assert!(s.contains("Undo changes : \\033[0;33mgit reset HEAD~1"));
assert!(s.contains(
"[ -n \"$_DIFF_FILE\" ] && printf \"${_LINTHIS_W}[linthis] Undo (by patch created) : \\033[0;33mgit apply -R $_DIFF_FILE"
));
}
#[test]
fn footer_view_undo_match_event_and_mode() {
let s = footer(
FooterOutcome::Applied,
HookTypeLabel::GitWithAgent,
FixCommitMode::Dirty,
&HookEvent::PreCommit,
"Format fixes left unstaged",
);
assert!(s.contains("View changes : \\033[0;36mgit diff${_LINTHIS_R}"));
assert!(s.contains("Undo changes : \\033[0;33mlinthis backup undo${_LINTHIS_R}"));
let s = footer(
FooterOutcome::Applied,
HookTypeLabel::GitWithAgent,
FixCommitMode::Squash,
&HookEvent::PreCommit,
"Format fixes folded into your commit",
);
assert!(s.contains("View changes : \\033[0;36mgit diff --cached${_LINTHIS_R}"));
assert!(s.contains("Undo changes : \\033[0;33mgit reset HEAD${_LINTHIS_R}"));
}
#[test]
fn footer_blocked_git_type_emits_upgrade_hint() {
let s = footer(
FooterOutcome::Blocked,
HookTypeLabel::Git,
FixCommitMode::Squash,
&HookEvent::PreCommit,
"✗ Pre-commit check failed",
);
assert!(
s.contains("Currently: --type git") && !s.contains("Currently: --type git-with-agent"),
"blocked+git must show plain --type git: {s}"
);
assert!(
s.contains("Want auto-fix + AI code review? Switch hook type"),
"blocked+git must suggest upgrade: {s}"
);
assert!(
s.contains("linthis hook install -g --type git-with-agent")
&& s.contains("--event pre-commit --force"),
"upgrade hint must include install command with event name: {s}"
);
}
#[test]
fn footer_blocked_agent_type_omits_upgrade_hint() {
let s = footer(
FooterOutcome::Blocked,
HookTypeLabel::GitWithAgent,
FixCommitMode::Squash,
&HookEvent::PreCommit,
"✗ Pre-commit check failed",
);
assert!(
!s.contains("Want auto-fix + AI code review"),
"agent-type must not suggest upgrade to itself: {s}"
);
assert!(
!s.contains("Currently: --type"),
"agent-type blocked must not print the Currently line: {s}"
);
}
#[test]
fn footer_switch_mode_block_marks_current_and_uses_correct_event_key() {
let s = footer(
FooterOutcome::Applied,
HookTypeLabel::GitWithAgent,
FixCommitMode::Fixup,
&HookEvent::PrePush,
"Created fixup commit with agent fixes",
);
assert!(
s.contains(
"→ Switch fix_commit_mode: \\033[0;36mlinthis config set hook.pre_push.fix_commit_mode <mode> -g"
),
"pre_push config key wrong: {s}"
);
assert!(s.contains("dirty (d) = fixes left in working tree"));
assert!(s.contains("squash (s) = fixes squashed into latest commit"));
assert!(s.contains(
"fixup (f) = fixes go into a separate fixup commit; run 'git push' again (current)"
));
assert!(!s.contains(
"dirty (d) = fixes left in working tree; push blocks, you re-stage manually (current)"
));
assert!(!s.contains(
"squash (s) = fixes squashed into latest commit; run 'git push' again (current)"
));
let s = footer(
FooterOutcome::Applied,
HookTypeLabel::GitWithAgent,
FixCommitMode::Dirty,
&HookEvent::PreCommit,
"Format fixes left unstaged",
);
assert!(
s.contains("hook.pre_commit.fix_commit_mode <mode>"),
"pre_commit config key wrong: {s}"
);
assert!(s.contains(
"dirty (d) = format left unstaged; commit blocks, you re-stage manually (current)"
));
assert!(s.contains("squash (s) = format auto-staged into your commit — one commit"));
assert!(s.contains(
"fixup (f) = commit proceeds; a separate auto-fix commit is created post-commit"
));
assert!(
!s.contains("squash (s) = format auto-staged into your commit — one commit (current)")
);
}
#[test]
fn footer_post_commit_uses_pre_commit_config_key() {
let s = footer(
FooterOutcome::Applied,
HookTypeLabel::GitWithAgent,
FixCommitMode::Fixup,
&HookEvent::PostCommit,
"Created fixup commit with format changes",
);
assert!(
s.contains("hook.pre_commit.fix_commit_mode"),
"post_commit footer must reference pre_commit config key: {s}"
);
}
#[test]
fn footer_clean_emits_header_only() {
let s = footer(
FooterOutcome::Clean,
HookTypeLabel::Git,
FixCommitMode::Squash,
&HookEvent::PrePush,
"✓ Pre-push check passed — push proceeding.",
);
assert!(s.contains("Pre-push check passed"));
assert!(!s.contains("View changes"));
assert!(!s.contains("Undo changes"));
assert!(!s.contains("→ Switch fix_commit_mode"));
assert!(!s.contains("Undo (by patch created)"));
assert!(
!s.contains("✓ Done — "),
"Clean outcome must not emit a closing hint: {s}"
);
}
#[test]
fn footer_end_hint_dirty_applied_points_at_retry() {
let s = footer(
FooterOutcome::Applied,
HookTypeLabel::GitWithAgent,
FixCommitMode::Dirty,
&HookEvent::PreCommit,
"Files formatted but not staged (dirty mode).",
);
assert!(
s.contains(
"✓ Done — review with 'git diff', stage with 'git add', then 'git commit' again."
),
"pre-commit dirty footer must end with an explicit Done marker: {s}"
);
let last = s.trim_end().lines().last().unwrap_or("");
assert!(
last.contains("✓ Done"),
"end-hint must be the last line of the footer, got: {last:?}"
);
}
#[test]
fn footer_end_hint_pre_push_uses_git_push() {
let s = footer(
FooterOutcome::Applied,
HookTypeLabel::GitWithAgent,
FixCommitMode::Dirty,
&HookEvent::PrePush,
"Agent fixes left in working tree (dirty mode).",
);
assert!(
s.contains("✓ Done") && s.contains("'git push' again"),
"pre-push end-hint must reference `git push`, not `git commit`: {s}"
);
}
#[test]
fn footer_end_hint_blocked_and_conflict() {
let blocked = footer(
FooterOutcome::Blocked,
HookTypeLabel::Git,
FixCommitMode::Squash,
&HookEvent::PreCommit,
"✗ Pre-commit check failed",
);
assert!(
blocked.contains("✗ Blocked — fix the errors above manually, then 'git commit' again."),
"Blocked must carry a ✗ end-hint: {blocked}"
);
let conflict = footer(
FooterOutcome::Conflict,
HookTypeLabel::GitWithAgent,
FixCommitMode::Squash,
&HookEvent::PreCommit,
"⚠ overlap",
);
assert!(
conflict.contains(
"⚠ Conflict — resolve markers in the working tree, then 'git commit' again."
),
"Conflict must carry a ⚠ end-hint: {conflict}"
);
}
#[test]
fn footer_end_hint_skipped_for_commit_msg_and_post_commit() {
for event in [HookEvent::CommitMsg, HookEvent::PostCommit] {
let s = footer(
FooterOutcome::Applied,
HookTypeLabel::GitWithAgent,
FixCommitMode::Fixup,
&event,
"✓ Agent fix applied",
);
assert!(
!s.contains("✓ Done — "),
"{event:?}: end-hint must be skipped (downstream signals handle it): {s}"
);
}
}
#[test]
fn footer_conflict_emits_view_but_no_undo() {
let s = footer(
FooterOutcome::Conflict,
HookTypeLabel::GitWithAgent,
FixCommitMode::Squash,
&HookEvent::PrePush,
"⚠ Your uncommitted changes overlap with agent fixes",
);
assert!(s.contains("View changes"));
assert!(!s.contains("Undo changes"));
assert!(!s.contains("Undo (by patch created)"));
assert!(s.contains("→ Switch fix_commit_mode"));
}
#[test]
fn footer_indent_is_respected_on_every_line() {
let indent = " "; let s = shell_hook_footer(&FooterCtx {
outcome: FooterOutcome::Applied,
hook_type: HookTypeLabel::GitWithAgent,
mode: FixCommitMode::Fixup,
event: &HookEvent::PrePush,
header: "✓ Agent fix applied",
indent,
});
for line in s.lines() {
if line.is_empty() {
continue;
}
assert!(
line.starts_with(indent),
"every emitted line must carry the caller's indent ({indent:?}): {line:?}"
);
}
}
}