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 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 exit 0\n\
fi\n\
# Compute changed files between remote and local\n\
_ZERO_SHA=\"0000000000000000000000000000000000000000\"\n\
if [ \"$_REMOTE_SHA\" = \"$_ZERO_SHA\" ]; then\n\
\x20 # New branch: diff against default branch\n\
\x20 _BASE=$(git rev-parse 'HEAD~1' 2>/dev/null || echo \"$_LOCAL_SHA\")\n\
else\n\
\x20 _BASE=\"$_REMOTE_SHA\"\n\
fi\n\
_PUSHED_FILES=$(git diff --name-only \"$_BASE\"..\"$_LOCAL_SHA\" 2>/dev/null | grep -v '^$')\n\
# No files to push = nothing to check\n\
if [ -z \"$_PUSHED_FILES\" ]; then\n\
\x20 exit 0\n\
fi\n\
set --\n\
while IFS= read -r _F; do set -- \"$@\" -i \"$_F\"; done <<_EOF_\n\
$_PUSHED_FILES\n\
_EOF_\n\
\n"
.to_string();
(preamble, "\"$_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)
}
}
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()
};
format!(
" if [ $LINTHIS_EXIT -ne 0 ]; then\n\
\x20\x20\x20 {agent_check}\
\x20\x20\x20 if [ \"$_LINTHIS_AGENT_OK\" = \"1\" ]; then\n\
\x20\x20\x20\x20\x20 echo \"[linthis] {error_msg}. Invoking {provider} to fix...\" >&2\n\
\x20\x20\x20\x20\x20 start_timer \"Fixing with {provider}\"\n\
\x20\x20\x20\x20\x20 {agent}\n\
\x20\x20\x20\x20\x20 stop_timer\n\
\x20\x20\x20\x20\x20 echo \"[linthis] Re-verifying...\" >&2\n\
\x20\x20\x20\x20\x20 $LINTHIS_CMD \"$@\"\n\
\x20\x20\x20\x20\x20 LINTHIS_EXIT=$?\n\
\x20\x20\x20 fi\n\
{new_msg_print}\
\x20 fi\n",
provider = provider,
agent = agent_cmd,
agent_check = agent_check,
error_msg = error_msg,
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 = if fix_provider.is_some() {
shell_timer_functions()
} else {
""
};
(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();
format!(
"#!/bin/sh\n\
# linthis-hook\n\
{timer}\
LINTHIS_CMD=\"{linthis}\"\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_CMD \"$@\"\n\
\x20\x20\x20 LINTHIS_EXIT=$?\n\
{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\
\x20 $LINTHIS_CMD \"$@\"\n\
\x20 LINTHIS_EXIT=$?\n\
{fix_direct}\
{review}\
\x20 exit $LINTHIS_EXIT\n\
fi\n",
timer = timer_block,
linthis = linthis_cmd_var,
pre_push_preamble = pre_push_preamble,
event = event_name,
local_hook_orig_args = local_hook_orig_args,
fix_local = fix_block,
fix_direct = fix_block_direct,
review = review_block,
)
}
pub(crate) fn agent_fix_bin(provider: &AgentFixProvider) -> &'static str {
match provider {
AgentFixProvider::Claude => "claude",
AgentFixProvider::Codex => "codex",
AgentFixProvider::Gemini => "gemini",
AgentFixProvider::Cursor => "cursor-agent",
AgentFixProvider::Droid => "droid",
AgentFixProvider::Auggie => "auggie",
AgentFixProvider::Codebuddy => "codebuddy",
AgentFixProvider::Openclaw => "openclaw",
}
}
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} --dangerously-skip-permissions '{}'",
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} --dangerously-skip-permissions '{}'",
escaped
),
AgentFixProvider::Openclaw => format!("openclaw agent{extra} --message '{}'", 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,
)
}
pub(crate) fn agent_fix_prompt_for_event(_hook_event: &HookEvent) -> String {
"You are working in a git worktree (an isolated copy of the repository). \
Your changes will be copied back to the main working tree after verification. \
Lint errors were found in staged files. Follow these steps: \
(1) Run 'linthis -m' to inspect all issues in this worktree. \
(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 -m' to verify all issues are resolved. \
(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 \"$_MSG_FILE\" ]; then\n\
{i} printf '\\033[0;32m[linthis] ✓ New message: %s\\033[0m\\n' \"$(cat \"$_MSG_FILE\")\" >&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 file at $_MSG_FILE: \
(1) run 'git diff --cached --stat' to understand what actually changed, \
(2) run 'git log -n 5 --oneline' to check recent commit style AND the language used \
(Chinese or English) — match that language for the description, \
(3) choose the correct type (feat/fix/refactor/perf/docs/style/test/build/ci/chore/revert) \
based on the diff, \
(4) rewrite to: type(scope)?: description — lowercase type, ≤72 chars, no trailing period. \
Overwrite $_MSG_FILE directly without asking. \
Verify with 'linthis cmsg $_MSG_FILE' until it passes.";
let escaped = prompt.replace('\\', "\\\\").replace('"', "\\\"");
let extra = provider_args
.filter(|a| !a.is_empty())
.map(|a| format!(" {a}"))
.unwrap_or_default();
let bin_cmd = match provider {
AgentFixProvider::Claude => format!(
"claude -p{extra} --dangerously-skip-permissions \"{}\"",
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} --dangerously-skip-permissions \"{}\"",
escaped
),
AgentFixProvider::Openclaw => format!("openclaw agent{extra} --message \"{}\"", escaped),
};
format!("_MSG_FILE=\"$1\"; {}", bin_cmd)
}
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
}
"#
}
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) Write the review to .linthis/review/result/review-$(date +%Y%m%d-%H%M%S).md \
(create the directory if needed). \
(4) If Critical 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. \
After fixing, display a Changes Summary showing each file, what changed, and why, \
plus the full git diff. Then re-run the review. \
Print '❌ Push blocked — fix Critical issues first' and exit 1 if issues remain. \
If Important issues only: print '⚠️ Push with caution'. \
If Minor or none: print '✅ Review passed'. \
Exit 0 unless Critical issues were found."
}
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 timer_fns = shell_timer_functions();
let review_box = shell_review_box_fn();
format!(
"#!/bin/sh\n\
{timer}\
{review_box}\
\n\
# Compute files changed in commits being pushed vs upstream (or HEAD~1 as fallback)\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\
# 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\
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\
\x20 _LINT_OUT=$({linthis} \"$@\" 2>&1)\n\
\x20 LINTHIS_EXIT=$?\n\
\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\
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\
\x20 exit 0\n\
fi\n\
\n\
# Check if agent provider is available before review\n\
{agent_check}\
if [ \"$_LINTHIS_AGENT_OK\" = \"0\" ]; then\n\
\x20 exit 0\n\
fi\n\
\n\
# Invoke agent code review before push\n\
echo \"[linthis] Invoking {provider} code review...\" >&2\n\
start_timer \"Reviewing with {provider}\"\n\
{agent}\n\
REVIEW_EXIT=$?\n\
stop_timer\n\
\n\
# Find the latest review report and check for critical issues\n\
REVIEW_REPORT=$(ls -t .linthis/review/result/review-*.md 2>/dev/null | head -1)\n\
if [ -n \"$REVIEW_REPORT\" ]; then\n\
\x20 # Check for actual critical issues (agent exit code is unreliable)\n\
\x20 _CRITICAL=$(awk '/^## Critical Issues/{{found=1;next}} found && /^## /{{found=0}} found && /^- \\[/{{print}}' \"$REVIEW_REPORT\")\n\
\x20 if [ -n \"$_CRITICAL\" ]; then\n\
\x20\x20\x20 _print_review_box \"blocked\" \"Critical issues found — fix before pushing\"\n\
\x20\x20\x20 echo \"[linthis] Review saved: $REVIEW_REPORT\" >&2\n\
\x20\x20\x20 exit 1\n\
\x20 else\n\
\x20\x20\x20 _print_review_box \"passed\" \"No critical issues found\"\n\
\x20\x20\x20 echo \"[linthis] Review saved: $REVIEW_REPORT\" >&2\n\
\x20 fi\n\
fi\n\
\n\
exit $REVIEW_EXIT\n",
timer = timer_fns,
review_box = review_box,
linthis = linthis_cmd,
provider = fix_provider,
agent = agent_cmd,
agent_check = shell_agent_availability_check(fix_provider),
)
}
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);
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 echo \"[linthis] {error_msg}. Invoking {provider} to fix...\" >&2\n\
\x20\x20\x20 # Backup staged files (safety net for linthis 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 # Create worktree for isolated agent fix\n\
\x20\x20\x20 _WT_TS=$(date +%s)\n\
\x20\x20\x20 _WORKTREE=\".linthis/worktree/fix-$_WT_TS\"\n\
\x20\x20\x20 _WT_BRANCH=\"linthis-fix-$_WT_TS\"\n\
\x20\x20\x20 _ORIG_DIR=$(pwd)\n\
\x20\x20\x20 # Trap: clean up worktree on interrupt\n\
\x20\x20\x20 trap 'echo \"[linthis] Interrupted, cleaning up worktree...\" >&2; cd \"$_ORIG_DIR\" 2>/dev/null; git worktree remove --force \"$_WORKTREE\" 2>/dev/null; git branch -D \"$_WT_BRANCH\" 2>/dev/null; exit 1' INT TERM\n\
\x20\x20\x20 git worktree add \"$_WORKTREE\" -b \"$_WT_BRANCH\" HEAD 2>/dev/null\n\
\x20\x20\x20 # Apply staged changes to worktree\n\
\x20\x20\x20 git diff --cached | git -C \"$_WORKTREE\" apply 2>/dev/null\n\
\x20\x20\x20 # Run agent in worktree\n\
\x20\x20\x20 start_timer \"Fixing with {provider}\"\n\
\x20\x20\x20 cd \"$_WORKTREE\" && {agent} ; cd \"$_ORIG_DIR\"\n\
\x20\x20\x20 stop_timer\n\
\x20\x20\x20 # Show changes summary\n\
\x20\x20\x20 echo \"\" >&2\n\
\x20\x20\x20 echo \"[linthis] ═══ Changes Summary ═══\" >&2\n\
\x20\x20\x20 _HAS_CHANGES=0\n\
\x20\x20\x20 for _F in $_STAGED_FILES; do\n\
\x20\x20\x20\x20\x20 if [ -f \"$_WORKTREE/$_F\" ] && ! diff -q \"$_F\" \"$_WORKTREE/$_F\" >/dev/null 2>&1; then\n\
\x20\x20\x20\x20\x20\x20\x20 echo \"[linthis] Modified: $_F\" >&2\n\
\x20\x20\x20\x20\x20\x20\x20 _HAS_CHANGES=1\n\
\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20 done\n\
\x20\x20\x20 if [ \"$_HAS_CHANGES\" = \"0\" ]; then\n\
\x20\x20\x20\x20\x20 echo \"[linthis] No changes made by agent\" >&2\n\
\x20\x20\x20 else\n\
\x20\x20\x20\x20\x20 echo \"\" >&2\n\
\x20\x20\x20\x20\x20 for _F in $_STAGED_FILES; do\n\
\x20\x20\x20\x20\x20\x20\x20 if [ -f \"$_WORKTREE/$_F\" ] && ! diff -q \"$_F\" \"$_WORKTREE/$_F\" >/dev/null 2>&1; then\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20 diff -u \"$_F\" \"$_WORKTREE/$_F\" 2>/dev/null | head -50 >&2\n\
\x20\x20\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20\x20\x20 done\n\
\x20\x20\x20 fi\n\
\x20\x20\x20 echo \"[linthis] ═══════════════════════\" >&2\n\
\x20\x20\x20 echo \"\" >&2\n\
\x20\x20\x20 # Copy fixed files back to main working tree\n\
\x20\x20\x20 for _F in $_STAGED_FILES; do\n\
\x20\x20\x20\x20\x20 if [ -f \"$_WORKTREE/$_F\" ]; then\n\
\x20\x20\x20\x20\x20\x20\x20 cp \"$_WORKTREE/$_F\" \"$_F\" 2>/dev/null\n\
\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20 done\n\
\x20\x20\x20 # Re-stage and clean up worktree\n\
\x20\x20\x20 if [ -n \"$_STAGED_FILES\" ]; then\n\
\x20\x20\x20\x20\x20 echo \"$_STAGED_FILES\" | xargs git add\n\
\x20\x20\x20 fi\n\
\x20\x20\x20 git worktree remove --force \"$_WORKTREE\" 2>/dev/null\n\
\x20\x20\x20 git branch -D \"$_WT_BRANCH\" 2>/dev/null\n\
\x20\x20\x20 trap - INT TERM\n\
\x20\x20\x20 # Re-verify after agent fix\n\
\x20\x20\x20 echo \"[linthis] Re-verifying...\" >&2\n\
\x20\x20\x20 $LINTHIS_CMD\n\
\x20\x20\x20 LINTHIS_EXIT=$?\n\
\x20 fi\n",
agent_check = agent_check,
linthis = linthis_cmd,
provider = fix_provider,
agent = agent_cmd,
error_msg = error_msg,
)
}
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);
}
let agent_cmd = if matches!(hook_event, HookEvent::CommitMsg) {
agent_fix_headless_cmd_commit_msg(fix_provider, provider_args)
} else {
let prompt = agent_fix_prompt_for_event(hook_event);
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 new_msg_print = if matches!(hook_event, HookEvent::CommitMsg) {
agent_fix_show_fixed_cmsg(" ")
} else {
String::new()
};
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\
# Skip entirely if no staged files (empty commit)\n\
if [ -z \"$_STAGED_FILES\" ]; then\n\
\x20 exit 0\n\
fi\n\
\n\
$LINTHIS_CMD\n\
LINTHIS_EXIT=$?\n\
# Re-stage files modified by linthis -f (auto-format), regardless of exit code\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_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()
}
}
}
pub(crate) fn hook_action(hook_event: &HookEvent) -> &'static str {
match hook_event {
HookEvent::PreCommit => "commit",
HookEvent::PrePush => "push",
HookEvent::CommitMsg => "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)))
.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 {
let parsed = match p.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,
};
return parsed.ok_or_else(|| {
eprintln!(
"{}: Unknown agent fix provider '{}'. Valid: claude, codex, gemini, cursor, droid, auggie, codebuddy, openclaw",
"Error".red(), p
);
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));
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,
}
}