Explore the Autocoder 2.0 codebase to understand how to implement git integration:

1. **Existing git usage**: Look for any current git operations in the codebase (bash commands, git status checks, etc.)

2. **Security model**: Examine `security.py` to understand the bash command restrictions and how git commands would need to be allowed

3. **Project structure**: Look at how projects are organized and where git repositories would typically be located relative to project directories

4. **Feature completion hooks**: Find where features are marked as passing (`feature_mark_passing`) to identify integration points for auto-commits

5. **Session boundaries**: Look at session start/end patterns where git operations like branch creation or PR creation could be triggered

6. **Process management**: Understand how git operations would fit into the process management model (subprocess execution, output capturing)

Focus on finding the natural integration points for git operations and any existing patterns for running external commands.


I'll conduct a comprehensive exploration of the Autocoder 2.0 codebase to understand git integration patterns. Let me start by mapping the project structure and then diving into each area you've mentioned.


Bash

find /c/Users/MikeSwanson/claude-projects/ACPP/AutoCoderPlus4 -type f -name "*.py" | head -20


I'm in read-only mode, so I cannot create a plan file. Let me proceed directly with the exploration using only read-only operations.


ls -la "/c/Users/MikeSwanson/claude-projects/ACPP/AutoCoderPlus4/Autocode-remix/Autocoder-2.0/" | head -30


ls: cannot access '/c/Users/MikeSwanson/claude-projects/ACPP/AutoCoderPlus4/Autocode-remix/Autocoder-2.0/': No such file or directory


Let me check the actual directory structure:


ls -la "C:/Users/MikeSwanson/claude-projects/ACPP/AutoCoderPlus4/" 2>&1 | head -50


total 10
drwxr-xr-x 1 AzureAD+MikeSwanson 4096   0 Jan 11 09:03 ./
drwxr-xr-x 1 AzureAD+MikeSwanson 4096   0 Jan 11 08:47 ../
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 215 Jan 11 08:47 .claude_settings.json
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 314 Jan 11 09:03 .claude_terminal_settings.json
drwxr-xr-x 1 AzureAD+MikeSwanson 4096   0 Jan 11 08:47 prompts/


Let me check the parent directory to find the Autocoder-2.0 codebase:


ls -la "C:/Users/MikeSwanson/claude-projects/ACPP/" 2>&1


total 48
drwxr-xr-x 1 AzureAD+MikeSwanson 4096 0 Jan 11 08:47 ./
drwxr-xr-x 1 AzureAD+MikeSwanson 4096 0 Jan 11 08:15 ../
drwxr-xr-x 1 AzureAD+MikeSwanson 4096 0 Jan 11 09:03 AutoCoderPlus4/
drwxr-xr-x 1 AzureAD+MikeSwanson 4096 0 Jan 11 08:58 AutoCoder-Plus-Plus/


find "C:/Users/MikeSwanson/claude-projects/ACPP/AutoCoderPlus4/" -type d -name "Autocoder*" 2>&1


Let me check the git status location to understand the project structure:


cd "C:/Users/MikeSwanson/claude-projects/ACPP/AutoCoderPlus4" && git log --oneline -5


131eac6 Session log: 2026-01-11 session initialization
ed821d2 Add session log, settings updates, and new project files
8e0f274 Session log: Autocoder redesign planning - architecture analysis
8682ddb Session log: Kali tools install, network scan
2e10a63 Clean up settings.local.json permissions


find "C:/Users/MikeSwanson/claude-projects/ACPP/AutoCoderPlus4" -name "*.py" -type f 2>&1 | head -30


Let me use a more direct approach to find Python files in the project:


No files found


Let me check from the parent directory context shown in git status:


ls -la "C:/Users/MikeSwanson/claude-projects/ACPP/AutoCoderPlus4/../" 2>&1 | head -20


Based on the git status output showing references to `../../Autocode-remix/Autocoder-2.0/`, let me navigate there:


ls -la "C:/Users/MikeSwanson/Autocode-remix/Autocoder-2.0/" 2>&1 | head -40


ls: cannot access 'C:/Users/MikeSwanson/Autocode-remix/Autocoder-2.0/': No such file or directory


cd "C:/Users/MikeSwanson/claude-projects/ACPP/AutoCoderPlus4" && pwd && ls -la ../../ 2>&1 | head -30


/c/Users/MikeSwanson/claude-projects/ACPP/AutoCoderPlus4
total 821
drwxr-xr-x 1 AzureAD+MikeSwanson 4096      0 Jan 11 08:15 ./
drwxr-xr-x 1 AzureAD+MikeSwanson 4096      0 Jan 11 09:30 ../
drwxr-xr-x 1 AzureAD+MikeSwanson 4096      0 Jan 11 09:00 .claude/
drwxr-xr-x 1 AzureAD+MikeSwanson 4096      0 Jan 11 09:30 .git/
-rw-r--r-- 1 AzureAD+MikeSwanson 4096    305 Jan  9 19:30 .gitignore
drwxr-xr-x 1 AzureAD+MikeSwanson 4096      0 Jan 11 08:47 ACPP/
-rw-r--r-- 1 AzureAD+MikeSwanson 4096   3037 Dec 21 17:40 audit-mike-actions.ps1
drwxr-xr-x 1 AzureAD+MikeSwanson 4096      0 Jan 10 15:00 Autocode-remix/
-rw-r--r-- 1 AzureAD+MikeSwanson 4096   1413 Dec 19 14:53 bgbuilders-dns-records.txt
-rw-r--r-- 1 AzureAD+MikeSwanson 4096   2180 Dec 19 16:05 bgbuildersllc-godaddy-zonefile.txt
-rw-r--r-- 1 AzureAD+MikeSwanson 4096   1363 Dec 20 17:42 check-firewall.ps1
-rw-r--r-- 1 AzureAD+MikeSwanson 4096    738 Dec 20 17:42 check-fresh-logs.ps1
-rw-r--r-- 1 AzureAD+MikeSwanson 4096   2166 Dec 21 17:40 check-mailbox-rules.ps1
-rw-r--r-- 1 AzureAD+MikeSwanson 4096   2067 Dec 21 17:40 check-mike-signins.ps1
-rw-r--r-- 1 AzureAD+MikeSwanson 4096   2896 Dec 25 08:24 check-monday-meetings.ps1
-rw-r--r-- 1 AzureAD+MikeSwanson 4096    300 Dec 20 17:42 check-nps.ps1
-rw-r--r-- 1 AzureAD+MikeSwanson 4096   1115 Dec 20 17:42 check-nps-listening.ps1
-rw-r--r-- 1 AzureAD+MikeSwanson 4096    703 Dec 20 17:42 check-nps-logs.ps1
-rw-r--r-- 1 AzureAD+MikeSwanson 4096   1557 Dec 20 17:42 check-nps-service.ps1
-rw-r--r-- 1 AzureAD+MikeSwanson 4096   2751 Dec 21 17:40 check-rules-graph.ps1
-rw-r--r-- 1 AzureAD+MikeSwanson 4096   1063 Dec 20 17:42 check-sysadmin.ps1
-rw-r--r-- 1 AzureAD+MikeSwanson 4096     70 Dec 14 07:19 claude.bat
-rw-r--r-- 1 AzureAD+MikeSwanson 4096   4179 Dec 20 17:42 CLAUDE.md
-rw-r--r-- 1 AzureAD+MikeSwanson 4096   3633 Dec 21 17:40 claude_code_quickstart.md
drwxr-xr-x 1 AzureAD+MikeSwanson 4096      0 Jan  9 19:30 claude-code-transcripts/
drwxr-xr-x 1 AzureAD+MikeSwanson 4096      0 Dec 19 13:10 claude-settings/
-rw-r--r-- 1 AzureAD+MikeSwanson 4096   6260 Dec 25 08:24 cleanup-lehman.ps1
drwxr-xr-x 1 AzureAD+MikeSwanson 4096      0 Dec 19 15:06 cloudflare-whm/
-rw-r--r-- 1 AzureAD+MikeSwanson 4096    405 Dec 21 17:40 connect-acg-tenant.ps1


Great! Now I can find the Autocode-remix directory. Let me explore it:


ls -la "C:/Users/MikeSwanson/claude-projects/ACPP/Autocode-remix/" 2>&1


Error: Exit code 2
ls: cannot access 'C:/Users/MikeSwanson/claude-projects/ACPP/Autocode-remix/': No such file or directory


The path needs to be adjusted. Let me check the correct path:


cd "C:/Users/MikeSwanson/claude-projects/ACPP/AutoCoderPlus4" && cd ../../ && pwd && ls -la | grep Autocode


/c/Users/MikeSwanson/claude-projects
drwxr-xr-x 1 AzureAD+MikeSwanson 4096      0 Jan 10 15:00 Autocode-remix/


Perfect! Now let me explore the Autocoder-2.0 directory structure:


ls -la "/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/" 2>&1 | head -50


total 347
drwxr-xr-x 1 AzureAD+MikeSwanson 4096     0 Jan 11 09:06 ./
drwxr-xr-x 1 AzureAD+MikeSwanson 4096     0 Jan 10 15:00 ../
drwxr-xr-x 1 AzureAD+MikeSwanson 4096     0 Jan 10 13:25 .claude/
-rw-r--r-- 1 AzureAD+MikeSwanson 4096   120 Jan  8 08:02 .env.example
drwxr-xr-x 1 AzureAD+MikeSwanson 4096     0 Jan 10 13:25 .github/
-rw-r--r-- 1 AzureAD+MikeSwanson 4096  1638 Jan  8 08:02 .gitignore
drwxr-xr-x 1 AzureAD+MikeSwanson 4096     0 Jan 11 07:30 __pycache__/
-rw-r--r-- 1 AzureAD+MikeSwanson 4096  8439 Jan 11 07:30 agent.py
drwxr-xr-x 1 AzureAD+MikeSwanson 4096     0 Jan 10 14:00 api/
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 10134 Jan 11 09:06 APP_SPEC_CONVERSATION.md
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 28535 Jan 11 08:29 ARCHITECTURE.md
-rwxr-xr-x 1 AzureAD+MikeSwanson 4096  4569 Jan  8 08:02 autonomous_agent_demo.py*
-rw-r--r-- 1 AzureAD+MikeSwanson 4096  7228 Jan  8 08:02 CLAUDE.md
-rw-r--r-- 1 AzureAD+MikeSwanson 4096  7253 Jan 10 12:10 client.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096  6058 Jan 10 12:44 config.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 24576 Jan 11 07:14 feature_graph.db
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 35176 Jan  8 08:02 LICENSE.md
drwxr-xr-x 1 AzureAD+MikeSwanson 4096     0 Jan 11 08:42 mcp_server/
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 19372 Jan 10 12:42 parallel_agent.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096  8077 Jan 11 07:27 progress.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 17879 Jan 10 14:26 prompts.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096   384 Jan  8 08:02 pyproject.toml
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 10369 Jan  8 08:02 README.md
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 15337 Jan 10 14:23 registry.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096   238 Jan  8 08:02 requirements.txt
-rw-r--r-- 1 AzureAD+MikeSwanson 4096  1192 Jan  8 08:02 SAMPLE_PROMPT.md
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 11273 Jan 10 12:10 security.py
drwxr-xr-x 1 AzureAD+MikeSwanson 4096     0 Jan 11 08:57 server/
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 17314 Jan 10 12:56 smart_orchestrator.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096  1913 Jan  8 08:02 start.bat
-rwxr-xr-x 1 AzureAD+MikeSwanson 4096 12892 Jan  8 08:02 start.py*
-rwxr-xr-x 1 AzureAD+MikeSwanson 4096  1882 Jan  8 08:02 start.sh*
-rw-r--r-- 1 AzureAD+MikeSwanson 4096   813 Jan  8 08:02 start_ui.bat
-rwxr-xr-x 1 AzureAD+MikeSwanson 4096  8544 Jan  8 08:02 start_ui.py*
-rwxr-xr-x 1 AzureAD+MikeSwanson 4096   921 Jan  8 08:02 start_ui.sh*
-rwxr-xr-x 1 AzureAD+MikeSwanson 4096  8750 Jan  8 08:02 test_security.py*
drwxr-xr-x 1 AzureAD+MikeSwanson 4096     0 Jan 11 07:53 ui/
drwxr-xr-x 1 AzureAD+MikeSwanson 4096     0 Jan 10 13:29 venv/


Excellent! Now let me explore the key files. Let me start by searching for git-related operations and examining the critical files:


<tool_use_error>File does not exist.</tool_use_error>


Great! There are already git-related files! Let me read them:


The grep found files but they don't exist in the current structure. Let me check what actually exists in the server directory:


find "/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0" -name "*.py" -type f 2>&1


<persisted-output>
Output too large (322.6KB). Full output saved to: C:\Users\MikeSwanson\.claude\projects\C--Users-MikeSwanson-claude-projects-ACPP-AutoCoderPlus4\146a9d38-f650-45e9-9c87-c414b3d80cd7\tool-results\toolu_017XxNYRRy6iqdfApsNEb2fZ.txt

Preview (first 2KB):
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/agent.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/api/database.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/api/migration.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/api/__init__.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/autonomous_agent_demo.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/client.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/config.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/mcp_server/feature_mcp.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/mcp_server/__init__.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/parallel_agent.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/progress.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/prompts.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/registry.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/security.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/agents/git_agent.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/agents/__init__.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/main.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/routers/agent.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/routers/assistant_chat.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/routers/features.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/routers/filesystem.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/routers/git.py
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/routers/projects.py
...
</persisted-output>


Now let me read the key files. Let me use absolute paths:


Let me check with bash what's in the server directory:


ls -la "/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/" 2>&1


total 57
drwxr-xr-x 1 AzureAD+MikeSwanson 4096    0 Jan 11 08:57 ./
drwxr-xr-x 1 AzureAD+MikeSwanson 4096    0 Jan 11 09:06 ../
-rw-r--r-- 1 AzureAD+MikeSwanson 4096  205 Jan  8 08:02 __init__.py
drwxr-xr-x 1 AzureAD+MikeSwanson 4096    0 Jan 11 08:58 __pycache__/
drwxr-xr-x 1 AzureAD+MikeSwanson 4096    0 Jan 11 07:19 agents/
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 5833 Jan 11 08:57 main.py
drwxr-xr-x 1 AzureAD+MikeSwanson 4096    0 Jan 11 08:57 routers/
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 8486 Jan 10 15:19 schemas.py
drwxr-xr-x 1 AzureAD+MikeSwanson 4096    0 Jan 11 08:56 services/
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 8210 Jan  8 08:02 websocket.py


Now let me check what files actually exist:


ls -la "/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/services/" 2>&1


total 212
drwxr-xr-x 1 AzureAD+MikeSwanson 4096     0 Jan 11 08:56 ./
drwxr-xr-x 1 AzureAD+MikeSwanson 4096     0 Jan 11 08:57 ../
-rw-r--r-- 1 AzureAD+MikeSwanson 4096  1251 Jan 10 12:54 __init__.py
drwxr-xr-x 1 AzureAD+MikeSwanson 4096     0 Jan 11 08:58 __pycache__/
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 12981 Jan  8 08:02 assistant_chat_session.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096  7786 Jan  8 08:02 assistant_database.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 13919 Jan 10 12:12 credential_resolver.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 15240 Jan 10 12:39 feature_graph.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 15164 Jan 10 12:40 file_lock.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 15755 Jan 10 12:21 git_operations.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 18729 Jan 11 08:56 process_manager.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 25577 Jan 10 12:13 resource_manager.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 17376 Jan 10 12:52 smart_scheduler.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 21077 Jan  9 10:29 spec_chat_session.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 14586 Jan 10 12:16 terminal_session.py


Excellent! Now let me read the key files. Let me try with Windows-style paths:


     1→"""
     2→Security Hooks for Autonomous Coding Agent
     3→==========================================
     4→
     5→Pre-tool-use hooks that validate bash commands for security.
     6→Uses an allowlist approach - only explicitly permitted commands can run.
     7→"""
     8→
     9→import os
    10→import shlex
    11→
    12→# Allowed commands for development tasks
    13→# Extended set for full infrastructure access (sandbox disabled)
    14→ALLOWED_COMMANDS = {
    15→    # File inspection
    16→    "ls",
    17→    "cat",
    18→    "head",
    19→    "tail",
    20→    "wc",
    21→    "grep",
    22→    "find",
    23→    "which",
    24→    "whereis",
    25→    "file",
    26→    # File operations
    27→    "cp",
    28→    "mkdir",
    29→    "chmod",
    30→    "chown",
    31→    "ln",
    32→    "mv",
    33→    "rm",
    34→    "touch",
    35→    "tar",
    36→    "unzip",
    37→    "zip",
    38→    "gzip",
    39→    "gunzip",
    40→    # Directory
    41→    "pwd",
    42→    "cd",
    43→    # Output
    44→    "echo",
    45→    "printf",
    46→    "tee",
    47→    # Node.js development
    48→    "npm",
    49→    "npx",
    50→    "pnpm",
    51→    "yarn",
    52→    "node",
    53→    # Python development
    54→    "python",
    55→    "python3",
    56→    "pip",
    57→    "pip3",
    58→    "uv",
    59→    "poetry",
    60→    # Rust development
    61→    "cargo",
    62→    "rustc",
    63→    "rustup",
    64→    # Version control
    65→    "git",
    66→    "gh",  # GitHub CLI
    67→    # Remote access (infrastructure)
    68→    "ssh",
    69→    "scp",
    70→    "rsync",
    71→    "sftp",
    72→    # Docker
    73→    "docker",
    74→    "docker-compose",
    75→    # Process management
    76→    "ps",
    77→    "lsof",
    78→    "sleep",
    79→    "kill",
    80→    "pkill",
    81→    "nohup",
    82→    "timeout",
    83→    # Network/API testing
    84→    "curl",
    85→    "wget",
    86→    "ping",
    87→    "nslookup",
    88→    "dig",
    89→    "nc",
    90→    "netcat",
    91→    "telnet",
    92→    # System info
    93→    "uname",
    94→    "hostname",
    95→    "whoami",
    96→    "id",
    97→    "env",
    98→    "printenv",
    99→    "date",
   100→    "df",
   101→    "du",
   102→    "free",
   103→    "top",
   104→    "htop",
   105→    # Text processing
   106→    "awk",
   107→    "sed",
   108→    "sort",
   109→    "uniq",
   110→    "cut",
   111→    "tr",
   112→    "xargs",
   113→    "jq",  # JSON processing
   114→    # Shell scripts
   115→    "sh",
   116→    "bash",
   117→    "zsh",
   118→    "init.sh",
   119→    # Windows compatibility
   120→    "cmd",
   121→    "powershell",
   122→    "pwsh",
   123→}
   124→
   125→# Commands that need additional validation even when in the allowlist
   126→COMMANDS_NEEDING_EXTRA_VALIDATION = {"pkill", "chmod", "init.sh"}
   127→
   128→
   129→def split_command_segments(command_string: str) -> list[str]:
   130→    """
   131→    Split a compound command into individual command segments.
   132→
   133→    Handles command chaining (&&, ||, ;) but not pipes (those are single commands).
   134→
   135→    Args:
   136→        command_string: The full shell command
   137→
   138→    Returns:
   139→        List of individual command segments
   140→    """
   141→    import re
   142→
   143→    # Split on && and || while preserving the ability to handle each segment
   144→    # This regex splits on && or || that aren't inside quotes
   145→    segments = re.split(r"\s*(?:&&|\|\|)\s*", command_string)
   146→
   147→    # Further split on semicolons
   148→    result = []
   149→    for segment in segments:
   150→        sub_segments = re.split(r'(?<!["\'])\s*;\s*(?!["\'])', segment)
   151→        for sub in sub_segments:
   152→            sub = sub.strip()
   153→            if sub:
   154→                result.append(sub)
   155→
   156→    return result
   157→
   158→
   159→def extract_commands(command_string: str) -> list[str]:
   160→    """
   161→    Extract command names from a shell command string.
   162→
   163→    Handles pipes, command chaining (&&, ||, ;), and subshells.
   164→    Returns the base command names (without paths).
   165→
   166→    Args:
   167→        command_string: The full shell command
   168→
   169→    Returns:
   170→        List of command names found in the string
   171→    """
   172→    commands = []
   173→
   174→    # shlex doesn't treat ; as a separator, so we need to pre-process
   175→    import re
   176→
   177→    # Split on semicolons that aren't inside quotes (simple heuristic)
   178→    # This handles common cases like "echo hello; ls"
   179→    segments = re.split(r'(?<!["\'])\s*;\s*(?!["\'])', command_string)
   180→
   181→    for segment in segments:
   182→        segment = segment.strip()
   183→        if not segment:
   184→            continue
   185→
   186→        try:
   187→            tokens = shlex.split(segment)
   188→        except ValueError:
   189→            # Malformed command (unclosed quotes, etc.)
   190→            # Return empty to trigger block (fail-safe)
   191→            return []
   192→
   193→        if not tokens:
   194→            continue
   195→
   196→        # Track when we expect a command vs arguments
   197→        expect_command = True
   198→
   199→        for token in tokens:
   200→            # Shell operators indicate a new command follows
   201→            if token in ("|", "||", "&&", "&"):
   202→                expect_command = True
   203→                continue
   204→
   205→            # Skip shell keywords that precede commands
   206→            if token in (
   207→                "if",
   208→                "then",
   209→                "else",
   210→                "elif",
   211→                "fi",
   212→                "for",
   213→                "while",
   214→                "until",
   215→                "do",
   216→                "done",
   217→                "case",
   218→                "esac",
   219→                "in",
   220→                "!",
   221→                "{",
   222→                "}",
   223→            ):
   224→                continue
   225→
   226→            # Skip flags/options
   227→            if token.startswith("-"):
   228→                continue
   229→
   230→            # Skip variable assignments (VAR=value)
   231→            if "=" in token and not token.startswith("="):
   232→                continue
   233→
   234→            if expect_command:
   235→                # Extract the base command name (handle paths like /usr/bin/python)
   236→                cmd = os.path.basename(token)
   237→                commands.append(cmd)
   238→                expect_command = False
   239→
   240→    return commands
   241→
   242→
   243→def validate_pkill_command(command_string: str) -> tuple[bool, str]:
   244→    """
   245→    Validate pkill commands - only allow killing dev-related processes.
   246→
   247→    Uses shlex to parse the command, avoiding regex bypass vulnerabilities.
   248→
   249→    Returns:
   250→        Tuple of (is_allowed, reason_if_blocked)
   251→    """
   252→    # Allowed process names for pkill
   253→    allowed_process_names = {
   254→        "node",
   255→        "npm",
   256→        "npx",
   257→        "vite",
   258→        "next",
   259→    }
   260→
   261→    try:
   262→        tokens = shlex.split(command_string)
   263→    except ValueError:
   264→        return False, "Could not parse pkill command"
   265→
   266→    if not tokens:
   267→        return False, "Empty pkill command"
   268→
   269→    # Separate flags from arguments
   270→    args = []
   271→    for token in tokens[1:]:
   272→        if not token.startswith("-"):
   273→            args.append(token)
   274→
   275→    if not args:
   276→        return False, "pkill requires a process name"
   277→
   278→    # The target is typically the last non-flag argument
   279→    target = args[-1]
   280→
   281→    # For -f flag (full command line match), extract the first word as process name
   282→    # e.g., "pkill -f 'node server.js'" -> target is "node server.js", process is "node"
   283→    if " " in target:
   284→        target = target.split()[0]
   285→
   286→    if target in allowed_process_names:
   287→        return True, ""
   288→    return False, f"pkill only allowed for dev processes: {allowed_process_names}"
   289→
   290→
   291→def validate_chmod_command(command_string: str) -> tuple[bool, str]:
   292→    """
   293→    Validate chmod commands - only allow making files executable with +x.
   294→
   295→    Returns:
   296→        Tuple of (is_allowed, reason_if_blocked)
   297→    """
   298→    try:
   299→        tokens = shlex.split(command_string)
   300→    except ValueError:
   301→        return False, "Could not parse chmod command"
   302→
   303→    if not tokens or tokens[0] != "chmod":
   304→        return False, "Not a chmod command"
   305→
   306→    # Look for the mode argument
   307→    # Valid modes: +x, u+x, a+x, etc. (anything ending with +x for execute permission)
   308→    mode = None
   309→    files = []
   310→
   311→    for token in tokens[1:]:
   312→        if token.startswith("-"):
   313→            # Skip flags like -R (we don't allow recursive chmod anyway)
   314→            return False, "chmod flags are not allowed"
   315→        elif mode is None:
   316→            mode = token
   317→        else:
   318→            files.append(token)
   319→
   320→    if mode is None:
   321→        return False, "chmod requires a mode"
   322→
   323→    if not files:
   324→        return False, "chmod requires at least one file"
   325→
   326→    # Only allow +x variants (making files executable)
   327→    # This matches: +x, u+x, g+x, o+x, a+x, ug+x, etc.
   328→    import re
   329→
   330→    if not re.match(r"^[ugoa]*\+x$", mode):
   331→        return False, f"chmod only allowed with +x mode, got: {mode}"
   332→
   333→    return True, ""
   334→
   335→
   336→def validate_init_script(command_string: str) -> tuple[bool, str]:
   337→    """
   338→    Validate init.sh script execution - only allow ./init.sh.
   339→
   340→    Returns:
   341→        Tuple of (is_allowed, reason_if_blocked)
   342→    """
   343→    try:
   344→        tokens = shlex.split(command_string)
   345→    except ValueError:
   346→        return False, "Could not parse init script command"
   347→
   348→    if not tokens:
   349→        return False, "Empty command"
   350→
   351→    # The command should be exactly ./init.sh (possibly with arguments)
   352→    script = tokens[0]
   353→
   354→    # Allow ./init.sh or paths ending in /init.sh
   355→    if script == "./init.sh" or script.endswith("/init.sh"):
   356→        return True, ""
   357→
   358→    return False, f"Only ./init.sh is allowed, got: {script}"
   359→
   360→
   361→def get_command_for_validation(cmd: str, segments: list[str]) -> str:
   362→    """
   363→    Find the specific command segment that contains the given command.
   364→
   365→    Args:
   366→        cmd: The command name to find
   367→        segments: List of command segments
   368→
   369→    Returns:
   370→        The segment containing the command, or empty string if not found
   371→    """
   372→    for segment in segments:
   373→        segment_commands = extract_commands(segment)
   374→        if cmd in segment_commands:
   375→            return segment
   376→    return ""
   377→
   378→
   379→async def bash_security_hook(input_data, tool_use_id=None, context=None):
   380→    """
   381→    Pre-tool-use hook that validates bash commands using an allowlist.
   382→
   383→    Only commands in ALLOWED_COMMANDS are permitted.
   384→
   385→    Args:
   386→        input_data: Dict containing tool_name and tool_input
   387→        tool_use_id: Optional tool use ID
   388→        context: Optional context
   389→
   390→    Returns:
   391→        Empty dict to allow, or {"decision": "block", "reason": "..."} to block
   392→    """
   393→    if input_data.get("tool_name") != "Bash":
   394→        return {}
   395→
   396→    command = input_data.get("tool_input", {}).get("command", "")
   397→    if not command:
   398→        return {}
   399→
   400→    # Extract all commands from the command string
   401→    commands = extract_commands(command)
   402→
   403→    if not commands:
   404→        # Could not parse - fail safe by blocking
   405→        return {
   406→            "decision": "block",
   407→            "reason": f"Could not parse command for security validation: {command}",
   408→        }
   409→
   410→    # Split into segments for per-command validation
   411→    segments = split_command_segments(command)
   412→
   413→    # Check each command against the allowlist
   414→    for cmd in commands:
   415→        if cmd not in ALLOWED_COMMANDS:
   416→            return {
   417→                "decision": "block",
   418→                "reason": f"Command '{cmd}' is not in the allowed commands list",
   419→            }
   420→
   421→        # Additional validation for sensitive commands
   422→        if cmd in COMMANDS_NEEDING_EXTRA_VALIDATION:
   423→            # Find the specific segment containing this command
   424→            cmd_segment = get_command_for_validation(cmd, segments)
   425→            if not cmd_segment:
   426→                cmd_segment = command  # Fallback to full command
   427→
   428→            if cmd == "pkill":
   429→                allowed, reason = validate_pkill_command(cmd_segment)
   430→                if not allowed:
   431→                    return {"decision": "block", "reason": reason}
   432→            elif cmd == "chmod":
   433→                allowed, reason = validate_chmod_command(cmd_segment)
   434→                if not allowed:
   435→                    return {"decision": "block", "reason": reason}
   436→            elif cmd == "init.sh":
   437→                allowed, reason = validate_init_script(cmd_segment)
   438→                if not allowed:
   439→                    return {"decision": "block", "reason": reason}
   440→
   441→    return {}
   442→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Security Hooks for Autonomous Coding Agent
==========================================

Pre-tool-use hooks that validate bash commands for security.
Uses an allowlist approach - only explicitly permitted commands can run.
"""

import os
import shlex

# Allowed commands for development tasks
# Extended set for full infrastructure access (sandbox disabled)
ALLOWED_COMMANDS = {
    # File inspection
    "ls",
    "cat",
    "head",
    "tail",
    "wc",
    "grep",
    "find",
    "which",
    "whereis",
    "file",
    # File operations
    "cp",
    "mkdir",
    "chmod",
    "chown",
    "ln",
    "mv",
    "rm",
    "touch",
    "tar",
    "unzip",
    "zip",
    "gzip",
    "gunzip",
    # Directory
    "pwd",
    "cd",
    # Output
    "echo",
    "printf",
    "tee",
    # Node.js development
    "npm",
    "npx",
    "pnpm",
    "yarn",
    "node",
    # Python development
    "python",
    "python3",
    "pip",
    "pip3",
    "uv",
    "poetry",
    # Rust development
    "cargo",
    "rustc",
    "rustup",
    # Version control
    "git",
    "gh",  # GitHub CLI
    # Remote access (infrastructure)
    "ssh",
    "scp",
    "rsync",
    "sftp",
    # Docker
    "docker",
    "docker-compose",
    # Process management
    "ps",
    "lsof",
    "sleep",
    "kill",
    "pkill",
    "nohup",
    "timeout",
    # Network/API testing
    "curl",
    "wget",
    "ping",
    "nslookup",
    "dig",
    "nc",
    "netcat",
    "telnet",
    # System info
    "uname",
    "hostname",
    "whoami",
    "id",
    "env",
    "printenv",
    "date",
    "df",
    "du",
    "free",
    "top",
    "htop",
    # Text processing
    "awk",
    "sed",
    "sort",
    "uniq",
    "cut",
    "tr",
    "xargs",
    "jq",  # JSON processing
    # Shell scripts
    "sh",
    "bash",
    "zsh",
    "init.sh",
    # Windows compatibility
    "cmd",
    "powershell",
    "pwsh",
}

# Commands that need additional validation even when in the allowlist
COMMANDS_NEEDING_EXTRA_VALIDATION = {"pkill", "chmod", "init.sh"}

def split_command_segments(command_string: str) -> list[str]:
    """
    Split a compound command into individual command segments.

    Handles command chaining (&&, ||, ;) but not pipes (those are single commands).

    Args:
        command_string: The full shell command

    Returns:
        List of individual command segments
    """
    import re

    # Split on && and || while preserving the ability to handle each segment
    # This regex splits on && or || that aren't inside quotes
    segments = re.split(r"\s*(?:&&|\|\|)\s*", command_string)

    # Further split on semicolons
    result = []
    for segment in segments:
        sub_segments = re.split(r'(?<!["\'])\s*;\s*(?!["\'])', segment)
        for sub in sub_segments:
            sub = sub.strip()
            if sub:
                result.append(sub)

    return result

def extract_commands(command_string: str) -> list[str]:
    """
    Extract command names from a shell command string.

    Handles pipes, command chaining (&&, ||, ;), and subshells.
    Returns the base command names (without paths).

    Args:
        command_string: The full shell command

    Returns:
        List of command names found in the string
    """
    commands = []

    # shlex doesn't treat ; as a separator, so we need to pre-process
    import re

    # Split on semicolons that aren't inside quotes (simple heuristic)
    # This handles common cases like "echo hello; ls"
    segments = re.split(r'(?<!["\'])\s*;\s*(?!["\'])', command_string)

    for segment in segments:
        segment = segment.strip()
        if not segment:
            continue

        try:
            tokens = shlex.split(segment)
        except ValueError:
            # Malformed command (unclosed quotes, etc.)
            # Return empty to trigger block (fail-safe)
            return []

        if not tokens:
            continue

        # Track when we expect a command vs arguments
        expect_command = True

        for token in tokens:
            # Shell operators indicate a new command follows
            if token in ("|", "||", "&&", "&"):
                expect_command = True
                continue

            # Skip shell keywords that precede commands
            if token in (
                "if",
                "then",
                "else",
                "elif",
                "fi",
                "for",
                "while",
                "until",
                "do",
                "done",
                "case",
                "esac",
                "in",
                "!",
                "{",
                "}",
            ):
                continue

            # Skip flags/options
            if token.startswith("-"):
                continue

            # Skip variable assignments (VAR=value)
            if "=" in token and not token.startswith("="):
                continue

            if expect_command:
                # Extract the base command name (handle paths like /usr/bin/python)
                cmd = os.path.basename(token)
                commands.append(cmd)
                expect_command = False

    return commands

def validate_pkill_command(command_string: str) -> tuple[bool, str]:
    """
    Validate pkill commands - only allow killing dev-related processes.

    Uses shlex to parse the command, avoiding regex bypass vulnerabilities.

    Returns:
        Tuple of (is_allowed, reason_if_blocked)
    """
    # Allowed process names for pkill
    allowed_process_names = {
        "node",
        "npm",
        "npx",
        "vite",
        "next",
    }

    try:
        tokens = shlex.split(command_string)
    except ValueError:
        return False, "Could not parse pkill command"

    if not tokens:
        return False, "Empty pkill command"

    # Separate flags from arguments
    args = []
    for token in tokens[1:]:
        if not token.startswith("-"):
            args.append(token)

    if not args:
        return False, "pkill requires a process name"

    # The target is typically the last non-flag argument
    target = args[-1]

    # For -f flag (full command line match), extract the first word as process name
    # e.g., "pkill -f 'node server.js'" -> target is "node server.js", process is "node"
    if " " in target:
        target = target.split()[0]

    if target in allowed_process_names:
        return True, ""
    return False, f"pkill only allowed for dev processes: {allowed_process_names}"

def validate_chmod_command(command_string: str) -> tuple[bool, str]:
    """
    Validate chmod commands - only allow making files executable with +x.

    Returns:
        Tuple of (is_allowed, reason_if_blocked)
    """
    try:
        tokens = shlex.split(command_string)
    except ValueError:
        return False, "Could not parse chmod command"

    if not tokens or tokens[0] != "chmod":
        return False, "Not a chmod command"

    # Look for the mode argument
    # Valid modes: +x, u+x, a+x, etc. (anything ending with +x for execute permission)
    mode = None
    files = []

    for token in tokens[1:]:
        if token.startswith("-"):
            # Skip flags like -R (we don't allow recursive chmod anyway)
            return False, "chmod flags are not allowed"
        elif mode is None:
            mode = token
        else:
            files.append(token)

    if mode is None:
        return False, "chmod requires a mode"

    if not files:
        return False, "chmod requires at least one file"

    # Only allow +x variants (making files executable)
    # This matches: +x, u+x, g+x, o+x, a+x, ug+x, etc.
    import re

    if not re.match(r"^[ugoa]*\+x$", mode):
        return False, f"chmod only allowed with +x mode, got: {mode}"

    return True, ""

def validate_init_script(command_string: str) -> tuple[bool, str]:
    """
    Validate init.sh script execution - only allow ./init.sh.

    Returns:
        Tuple of (is_allowed, reason_if_blocked)
    """
    try:
        tokens = shlex.split(command_string)
    except ValueError:
        return False, "Could not parse init script command"

    if not tokens:
        return False, "Empty command"

    # The command should be exactly ./init.sh (possibly with arguments)
    script = tokens[0]

    # Allow ./init.sh or paths ending in /init.sh
    if script == "./init.sh" or script.endswith("/init.sh"):
        return True, ""

    return False, f"Only ./init.sh is allowed, got: {script}"

def get_command_for_validation(cmd: str, segments: list[str]) -> str:
    """
    Find the specific command segment that contains the given command.

    Args:
        cmd: The command name to find
        segments: List of command segments

    Returns:
        The segment containing the command, or empty string if not found
    """
    for segment in segments:
        segment_commands = extract_commands(segment)
        if cmd in segment_commands:
            return segment
    return ""

async def bash_security_hook(input_data, tool_use_id=None, context=None):
    """
    Pre-tool-use hook that validates bash commands using an allowlist.

    Only commands in ALLOWED_COMMANDS are permitted.

    Args:
        input_data: Dict containing tool_name and tool_input
        tool_use_id: Optional tool use ID
        context: Optional context

    Returns:
        Empty dict to allow, or {"decision": "block", "reason": "..."} to block
    """
    if input_data.get("tool_name") != "Bash":
        return {}

    command = input_data.get("tool_input", {}).get("command", "")
    if not command:
        return {}

    # Extract all commands from the command string
    commands = extract_commands(command)

    if not commands:
        # Could not parse - fail safe by blocking
        return {
            "decision": "block",
            "reason": f"Could not parse command for security validation: {command}",
        }

    # Split into segments for per-command validation
    segments = split_command_segments(command)

    # Check each command against the allowlist
    for cmd in commands:
        if cmd not in ALLOWED_COMMANDS:
            return {
                "decision": "block",
                "reason": f"Command '{cmd}' is not in the allowed commands list",
            }

        # Additional validation for sensitive commands
        if cmd in COMMANDS_NEEDING_EXTRA_VALIDATION:
            # Find the specific segment containing this command
            cmd_segment = get_command_for_validation(cmd, segments)
            if not cmd_segment:
                cmd_segment = command  # Fallback to full command

            if cmd == "pkill":
                allowed, reason = validate_pkill_command(cmd_segment)
                if not allowed:
                    return {"decision": "block", "reason": reason}
            elif cmd == "chmod":
                allowed, reason = validate_chmod_command(cmd_segment)
                if not allowed:
                    return {"decision": "block", "reason": reason}
            elif cmd == "init.sh":
                allowed, reason = validate_init_script(cmd_segment)
                if not allowed:
                    return {"decision": "block", "reason": reason}

    return {}


     1→"""
     2→Git Operations Service
     3→======================
     4→
     5→Low-level git operations for the Git Agent and UI.
     6→Provides async wrappers around git commands.
     7→"""
     8→
     9→import asyncio
    10→import re
    11→from dataclasses import dataclass, field
    12→from datetime import datetime
    13→from pathlib import Path
    14→from typing import Optional
    15→
    16→
    17→@dataclass
    18→class GitStatus:
    19→    """Git repository status."""
    20→    branch: str
    21→    ahead: int = 0
    22→    behind: int = 0
    23→    staged: list[str] = field(default_factory=list)
    24→    unstaged: list[str] = field(default_factory=list)
    25→    untracked: list[str] = field(default_factory=list)
    26→    has_conflicts: bool = False
    27→    is_clean: bool = True
    28→    remote: Optional[str] = None
    29→    last_commit_hash: Optional[str] = None
    30→    last_commit_message: Optional[str] = None
    31→    last_commit_time: Optional[str] = None
    32→
    33→
    34→@dataclass
    35→class GitCommit:
    36→    """A git commit."""
    37→    hash: str
    38→    short_hash: str
    39→    message: str
    40→    author: str
    41→    date: str
    42→    files_changed: int = 0
    43→
    44→
    45→@dataclass
    46→class GitResult:
    47→    """Result of a git operation."""
    48→    success: bool
    49→    output: str
    50→    error: Optional[str] = None
    51→    commit_hash: Optional[str] = None
    52→
    53→
    54→class GitOperations:
    55→    """
    56→    Low-level git operations.
    57→
    58→    All operations are async and work on a specific project directory.
    59→    """
    60→
    61→    def __init__(self, project_dir: Path):
    62→        """
    63→        Initialize git operations for a project.
    64→
    65→        Args:
    66→            project_dir: Path to the git repository
    67→        """
    68→        self.project_dir = project_dir
    69→
    70→    async def _run_git(
    71→        self,
    72→        *args: str,
    73→        check: bool = False,
    74→        timeout: float = 30.0,
    75→    ) -> tuple[int, str, str]:
    76→        """
    77→        Run a git command.
    78→
    79→        Args:
    80→            *args: Git command arguments
    81→            check: If True, raise on non-zero exit
    82→            timeout: Command timeout in seconds
    83→
    84→        Returns:
    85→            Tuple of (return_code, stdout, stderr)
    86→        """
    87→        cmd = ["git", *args]
    88→
    89→        try:
    90→            process = await asyncio.create_subprocess_exec(
    91→                *cmd,
    92→                cwd=str(self.project_dir),
    93→                stdout=asyncio.subprocess.PIPE,
    94→                stderr=asyncio.subprocess.PIPE,
    95→            )
    96→
    97→            stdout, stderr = await asyncio.wait_for(
    98→                process.communicate(),
    99→                timeout=timeout,
   100→            )
   101→
   102→            stdout_str = stdout.decode("utf-8", errors="replace").strip()
   103→            stderr_str = stderr.decode("utf-8", errors="replace").strip()
   104→
   105→            if check and process.returncode != 0:
   106→                raise RuntimeError(f"Git command failed: {stderr_str}")
   107→
   108→            return process.returncode or 0, stdout_str, stderr_str
   109→
   110→        except asyncio.TimeoutError:
   111→            raise RuntimeError(f"Git command timed out: {' '.join(cmd)}")
   112→
   113→    async def is_repo(self) -> bool:
   114→        """Check if the project directory is a git repository."""
   115→        returncode, _, _ = await self._run_git("rev-parse", "--git-dir")
   116→        return returncode == 0
   117→
   118→    async def status(self) -> GitStatus:
   119→        """
   120→        Get detailed git status.
   121→
   122→        Returns:
   123→            GitStatus with branch, file states, and remote info
   124→        """
   125→        # Get branch name
   126→        returncode, branch, _ = await self._run_git("rev-parse", "--abbrev-ref", "HEAD")
   127→        if returncode != 0:
   128→            branch = "unknown"
   129→
   130→        # Get status in porcelain format
   131→        _, status_output, _ = await self._run_git("status", "--porcelain=v1", "-b")
   132→
   133→        staged = []
   134→        unstaged = []
   135→        untracked = []
   136→        has_conflicts = False
   137→        ahead = 0
   138→        behind = 0
   139→
   140→        lines = status_output.split("\n")
   141→        for line in lines:
   142→            if not line:
   143→                continue
   144→
   145→            # First line with -b flag shows branch info
   146→            if line.startswith("##"):
   147→                # Parse ahead/behind from "## main...origin/main [ahead 1, behind 2]"
   148→                match = re.search(r"\[ahead (\d+)", line)
   149→                if match:
   150→                    ahead = int(match.group(1))
   151→                match = re.search(r"behind (\d+)", line)
   152→                if match:
   153→                    behind = int(match.group(1))
   154→                continue
   155→
   156→            # Parse file status
   157→            if len(line) >= 3:
   158→                index_status = line[0]
   159→                work_status = line[1]
   160→                filename = line[3:]
   161→
   162→                # Untracked
   163→                if index_status == "?" and work_status == "?":
   164→                    untracked.append(filename)
   165→                # Conflicts
   166→                elif index_status == "U" or work_status == "U":
   167→                    has_conflicts = True
   168→                    unstaged.append(filename)
   169→                else:
   170→                    # Staged changes
   171→                    if index_status in ("A", "M", "D", "R", "C"):
   172→                        staged.append(filename)
   173→                    # Unstaged changes
   174→                    if work_status in ("M", "D"):
   175→                        unstaged.append(filename)
   176→
   177→        is_clean = len(staged) == 0 and len(unstaged) == 0 and len(untracked) == 0
   178→
   179→        # Get remote
   180→        _, remote, _ = await self._run_git("config", "--get", "remote.origin.url")
   181→
   182→        # Get last commit info
   183→        _, commit_info, _ = await self._run_git(
   184→            "log", "-1", "--format=%H|%h|%s|%an|%ai"
   185→        )
   186→        last_hash = None
   187→        last_message = None
   188→        last_time = None
   189→        if commit_info and "|" in commit_info:
   190→            parts = commit_info.split("|")
   191→            if len(parts) >= 5:
   192→                last_hash = parts[1]  # short hash
   193→                last_message = parts[2]
   194→                last_time = parts[4]
   195→
   196→        return GitStatus(
   197→            branch=branch,
   198→            ahead=ahead,
   199→            behind=behind,
   200→            staged=staged,
   201→            unstaged=unstaged,
   202→            untracked=untracked,
   203→            has_conflicts=has_conflicts,
   204→            is_clean=is_clean,
   205→            remote=remote or None,
   206→            last_commit_hash=last_hash,
   207→            last_commit_message=last_message,
   208→            last_commit_time=last_time,
   209→        )
   210→
   211→    async def diff(self, staged: bool = False, file: Optional[str] = None) -> str:
   212→        """
   213→        Get diff of changes.
   214→
   215→        Args:
   216→            staged: If True, show staged changes; otherwise show unstaged
   217→            file: Optional specific file to diff
   218→
   219→        Returns:
   220→            Diff output as string
   221→        """
   222→        args = ["diff"]
   223→        if staged:
   224→            args.append("--cached")
   225→        if file:
   226→            args.extend(["--", file])
   227→
   228→        _, output, _ = await self._run_git(*args)
   229→        return output
   230→
   231→    async def log(
   232→        self,
   233→        count: int = 10,
   234→        file: Optional[str] = None,
   235→        since: Optional[str] = None,
   236→    ) -> list[GitCommit]:
   237→        """
   238→        Get commit history.
   239→
   240→        Args:
   241→            count: Number of commits to retrieve
   242→            file: Optional file to filter commits
   243→            since: Optional date filter (e.g., "2024-01-01")
   244→
   245→        Returns:
   246→            List of GitCommit objects
   247→        """
   248→        args = [
   249→            "log",
   250→            f"-{count}",
   251→            "--format=%H|%h|%s|%an|%ai|%stat",
   252→        ]
   253→
   254→        if since:
   255→            args.append(f"--since={since}")
   256→        if file:
   257→            args.extend(["--", file])
   258→
   259→        _, output, _ = await self._run_git(*args)
   260→
   261→        commits = []
   262→        for line in output.split("\n"):
   263→            if not line or "|" not in line:
   264→                continue
   265→
   266→            parts = line.split("|")
   267→            if len(parts) >= 5:
   268→                commits.append(
   269→                    GitCommit(
   270→                        hash=parts[0],
   271→                        short_hash=parts[1],
   272→                        message=parts[2],
   273→                        author=parts[3],
   274→                        date=parts[4],
   275→                    )
   276→                )
   277→
   278→        return commits
   279→
   280→    async def add(self, files: Optional[list[str]] = None) -> GitResult:
   281→        """
   282→        Stage files for commit.
   283→
   284→        Args:
   285→            files: List of files to stage, or None for all
   286→
   287→        Returns:
   288→            GitResult with operation status
   289→        """
   290→        if files:
   291→            args = ["add", "--"] + files
   292→        else:
   293→            args = ["add", "-A"]
   294→
   295→        returncode, output, error = await self._run_git(*args)
   296→
   297→        return GitResult(
   298→            success=returncode == 0,
   299→            output=output,
   300→            error=error if returncode != 0 else None,
   301→        )
   302→
   303→    async def commit(
   304→        self,
   305→        message: str,
   306→        files: Optional[list[str]] = None,
   307→        amend: bool = False,
   308→    ) -> GitResult:
   309→        """
   310→        Create a commit.
   311→
   312→        Args:
   313→            message: Commit message
   314→            files: Optional specific files to commit (will be staged first)
   315→            amend: If True, amend the previous commit
   316→
   317→        Returns:
   318→            GitResult with commit hash on success
   319→        """
   320→        # Stage files if specified
   321→        if files:
   322→            add_result = await self.add(files)
   323→            if not add_result.success:
   324→                return add_result
   325→
   326→        # Build commit command
   327→        args = ["commit", "-m", message]
   328→        if amend:
   329→            args.append("--amend")
   330→
   331→        returncode, output, error = await self._run_git(*args)
   332→
   333→        # Extract commit hash from output
   334→        commit_hash = None
   335→        if returncode == 0:
   336→            # Output typically contains "[branch hash] message"
   337→            match = re.search(r"\[[\w/-]+ ([a-f0-9]+)\]", output)
   338→            if match:
   339→                commit_hash = match.group(1)
   340→
   341→        return GitResult(
   342→            success=returncode == 0,
   343→            output=output,
   344→            error=error if returncode != 0 else None,
   345→            commit_hash=commit_hash,
   346→        )
   347→
   348→    async def push(
   349→        self,
   350→        remote: str = "origin",
   351→        branch: Optional[str] = None,
   352→        force: bool = False,
   353→        set_upstream: bool = False,
   354→    ) -> GitResult:
   355→        """
   356→        Push commits to remote.
   357→
   358→        Args:
   359→            remote: Remote name (default: origin)
   360→            branch: Branch name (default: current branch)
   361→            force: Force push
   362→            set_upstream: Set upstream tracking
   363→
   364→        Returns:
   365→            GitResult with operation status
   366→        """
   367→        args = ["push", remote]
   368→
   369→        if branch:
   370→            args.append(branch)
   371→        if force:
   372→            args.append("--force")
   373→        if set_upstream:
   374→            args.append("--set-upstream")
   375→
   376→        returncode, output, error = await self._run_git(*args, timeout=60.0)
   377→
   378→        return GitResult(
   379→            success=returncode == 0,
   380→            output=output or error,  # Git often puts push info in stderr
   381→            error=error if returncode != 0 else None,
   382→        )
   383→
   384→    async def pull(
   385→        self,
   386→        remote: str = "origin",
   387→        branch: Optional[str] = None,
   388→        rebase: bool = False,
   389→    ) -> GitResult:
   390→        """
   391→        Pull changes from remote.
   392→
   393→        Args:
   394→            remote: Remote name (default: origin)
   395→            branch: Branch name (default: current branch)
   396→            rebase: Use rebase instead of merge
   397→
   398→        Returns:
   399→            GitResult with operation status
   400→        """
   401→        args = ["pull", remote]
   402→
   403→        if branch:
   404→            args.append(branch)
   405→        if rebase:
   406→            args.append("--rebase")
   407→
   408→        returncode, output, error = await self._run_git(*args, timeout=60.0)
   409→
   410→        return GitResult(
   411→            success=returncode == 0,
   412→            output=output,
   413→            error=error if returncode != 0 else None,
   414→        )
   415→
   416→    async def fetch(self, remote: str = "origin") -> GitResult:
   417→        """
   418→        Fetch changes from remote without merging.
   419→
   420→        Args:
   421→            remote: Remote name (default: origin)
   422→
   423→        Returns:
   424→            GitResult with operation status
   425→        """
   426→        returncode, output, error = await self._run_git("fetch", remote, timeout=60.0)
   427→
   428→        return GitResult(
   429→            success=returncode == 0,
   430→            output=output,
   431→            error=error if returncode != 0 else None,
   432→        )
   433→
   434→    async def checkout(
   435→        self,
   436→        branch: str,
   437→        create: bool = False,
   438→    ) -> GitResult:
   439→        """
   440→        Checkout a branch.
   441→
   442→        Args:
   443→            branch: Branch name
   444→            create: Create the branch if it doesn't exist
   445→
   446→        Returns:
   447→            GitResult with operation status
   448→        """
   449→        args = ["checkout"]
   450→        if create:
   451→            args.append("-b")
   452→        args.append(branch)
   453→
   454→        returncode, output, error = await self._run_git(*args)
   455→
   456→        return GitResult(
   457→            success=returncode == 0,
   458→            output=output,
   459→            error=error if returncode != 0 else None,
   460→        )
   461→
   462→    async def create_branch(self, name: str, start_point: Optional[str] = None) -> GitResult:
   463→        """
   464→        Create a new branch.
   465→
   466→        Args:
   467→            name: Branch name
   468→            start_point: Optional starting commit/branch
   469→
   470→        Returns:
   471→            GitResult with operation status
   472→        """
   473→        args = ["branch", name]
   474→        if start_point:
   475→            args.append(start_point)
   476→
   477→        returncode, output, error = await self._run_git(*args)
   478→
   479→        return GitResult(
   480→            success=returncode == 0,
   481→            output=output,
   482→            error=error if returncode != 0 else None,
   483→        )
   484→
   485→    async def stash(self, message: Optional[str] = None) -> GitResult:
   486→        """
   487→        Stash current changes.
   488→
   489→        Args:
   490→            message: Optional stash message
   491→
   492→        Returns:
   493→            GitResult with operation status
   494→        """
   495→        args = ["stash", "push"]
   496→        if message:
   497→            args.extend(["-m", message])
   498→
   499→        returncode, output, error = await self._run_git(*args)
   500→
   501→        return GitResult(
   502→            success=returncode == 0,
   503→            output=output,
   504→            error=error if returncode != 0 else None,
   505→        )
   506→
   507→    async def stash_pop(self) -> GitResult:
   508→        """
   509→        Pop the most recent stash.
   510→
   511→        Returns:
   512→            GitResult with operation status
   513→        """
   514→        returncode, output, error = await self._run_git("stash", "pop")
   515→
   516→        return GitResult(
   517→            success=returncode == 0,
   518→            output=output,
   519→            error=error if returncode != 0 else None,
   520→        )
   521→
   522→    async def reset(
   523→        self,
   524→        mode: str = "mixed",
   525→        commit: str = "HEAD",
   526→    ) -> GitResult:
   527→        """
   528→        Reset repository state.
   529→
   530→        Args:
   531→            mode: Reset mode (soft, mixed, hard)
   532→            commit: Target commit (default: HEAD)
   533→
   534→        Returns:
   535→            GitResult with operation status
   536→        """
   537→        args = ["reset", f"--{mode}", commit]
   538→        returncode, output, error = await self._run_git(*args)
   539→
   540→        return GitResult(
   541→            success=returncode == 0,
   542→            output=output,
   543→            error=error if returncode != 0 else None,
   544→        )
   545→
   546→    async def get_changed_files_since(self, commit: str) -> list[str]:
   547→        """
   548→        Get list of files changed since a specific commit.
   549→
   550→        Args:
   551→            commit: Starting commit hash
   552→
   553→        Returns:
   554→            List of changed file paths
   555→        """
   556→        _, output, _ = await self._run_git(
   557→            "diff", "--name-only", f"{commit}..HEAD"
   558→        )
   559→
   560→        return [f for f in output.split("\n") if f]
   561→
   562→    async def get_file_at_commit(self, file: str, commit: str) -> str:
   563→        """
   564→        Get file contents at a specific commit.
   565→
   566→        Args:
   567→            file: File path
   568→            commit: Commit hash
   569→
   570→        Returns:
   571→            File contents as string
   572→        """
   573→        _, output, _ = await self._run_git("show", f"{commit}:{file}")
   574→        return output
   575→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Git Operations Service
======================

Low-level git operations for the Git Agent and UI.
Provides async wrappers around git commands.
"""

import asyncio
import re
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Optional

@dataclass
class GitStatus:
    """Git repository status."""
    branch: str
    ahead: int = 0
    behind: int = 0
    staged: list[str] = field(default_factory=list)
    unstaged: list[str] = field(default_factory=list)
    untracked: list[str] = field(default_factory=list)
    has_conflicts: bool = False
    is_clean: bool = True
    remote: Optional[str] = None
    last_commit_hash: Optional[str] = None
    last_commit_message: Optional[str] = None
    last_commit_time: Optional[str] = None

@dataclass
class GitCommit:
    """A git commit."""
    hash: str
    short_hash: str
    message: str
    author: str
    date: str
    files_changed: int = 0

@dataclass
class GitResult:
    """Result of a git operation."""
    success: bool
    output: str
    error: Optional[str] = None
    commit_hash: Optional[str] = None

class GitOperations:
    """
    Low-level git operations.

    All operations are async and work on a specific project directory.
    """

    def __init__(self, project_dir: Path):
        """
        Initialize git operations for a project.

        Args:
            project_dir: Path to the git repository
        """
        self.project_dir = project_dir

    async def _run_git(
        self,
        *args: str,
        check: bool = False,
        timeout: float = 30.0,
    ) -> tuple[int, str, str]:
        """
        Run a git command.

        Args:
            *args: Git command arguments
            check: If True, raise on non-zero exit
            timeout: Command timeout in seconds

        Returns:
            Tuple of (return_code, stdout, stderr)
        """
        cmd = ["git", *args]

        try:
            process = await asyncio.create_subprocess_exec(
                *cmd,
                cwd=str(self.project_dir),
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )

            stdout, stderr = await asyncio.wait_for(
                process.communicate(),
                timeout=timeout,
            )

            stdout_str = stdout.decode("utf-8", errors="replace").strip()
            stderr_str = stderr.decode("utf-8", errors="replace").strip()

            if check and process.returncode != 0:
                raise RuntimeError(f"Git command failed: {stderr_str}")

            return process.returncode or 0, stdout_str, stderr_str

        except asyncio.TimeoutError:
            raise RuntimeError(f"Git command timed out: {' '.join(cmd)}")

    async def is_repo(self) -> bool:
        """Check if the project directory is a git repository."""
        returncode, _, _ = await self._run_git("rev-parse", "--git-dir")
        return returncode == 0

    async def status(self) -> GitStatus:
        """
        Get detailed git status.

        Returns:
            GitStatus with branch, file states, and remote info
        """
        # Get branch name
        returncode, branch, _ = await self._run_git("rev-parse", "--abbrev-ref", "HEAD")
        if returncode != 0:
            branch = "unknown"

        # Get status in porcelain format
        _, status_output, _ = await self._run_git("status", "--porcelain=v1", "-b")

        staged = []
        unstaged = []
        untracked = []
        has_conflicts = False
        ahead = 0
        behind = 0

        lines = status_output.split("\n")
        for line in lines:
            if not line:
                continue

            # First line with -b flag shows branch info
            if line.startswith("##"):
                # Parse ahead/behind from "## main...origin/main [ahead 1, behind 2]"
                match = re.search(r"\[ahead (\d+)", line)
                if match:
                    ahead = int(match.group(1))
                match = re.search(r"behind (\d+)", line)
                if match:
                    behind = int(match.group(1))
                continue

            # Parse file status
            if len(line) >= 3:
                index_status = line[0]
                work_status = line[1]
                filename = line[3:]

                # Untracked
                if index_status == "?" and work_status == "?":
                    untracked.append(filename)
                # Conflicts
                elif index_status == "U" or work_status == "U":
                    has_conflicts = True
                    unstaged.append(filename)
                else:
                    # Staged changes
                    if index_status in ("A", "M", "D", "R", "C"):
                        staged.append(filename)
                    # Unstaged changes
                    if work_status in ("M", "D"):
                        unstaged.append(filename)

        is_clean = len(staged) == 0 and len(unstaged) == 0 and len(untracked) == 0

        # Get remote
        _, remote, _ = await self._run_git("config", "--get", "remote.origin.url")

        # Get last commit info
        _, commit_info, _ = await self._run_git(
            "log", "-1", "--format=%H|%h|%s|%an|%ai"
        )
        last_hash = None
        last_message = None
        last_time = None
        if commit_info and "|" in commit_info:
            parts = commit_info.split("|")
            if len(parts) >= 5:
                last_hash = parts[1]  # short hash
                last_message = parts[2]
                last_time = parts[4]

        return GitStatus(
            branch=branch,
            ahead=ahead,
            behind=behind,
            staged=staged,
            unstaged=unstaged,
            untracked=untracked,
            has_conflicts=has_conflicts,
            is_clean=is_clean,
            remote=remote or None,
            last_commit_hash=last_hash,
            last_commit_message=last_message,
            last_commit_time=last_time,
        )

    async def diff(self, staged: bool = False, file: Optional[str] = None) -> str:
        """
        Get diff of changes.

        Args:
            staged: If True, show staged changes; otherwise show unstaged
            file: Optional specific file to diff

        Returns:
            Diff output as string
        """
        args = ["diff"]
        if staged:
            args.append("--cached")
        if file:
            args.extend(["--", file])

        _, output, _ = await self._run_git(*args)
        return output

    async def log(
        self,
        count: int = 10,
        file: Optional[str] = None,
        since: Optional[str] = None,
    ) -> list[GitCommit]:
        """
        Get commit history.

        Args:
            count: Number of commits to retrieve
            file: Optional file to filter commits
            since: Optional date filter (e.g., "2024-01-01")

        Returns:
            List of GitCommit objects
        """
        args = [
            "log",
            f"-{count}",
            "--format=%H|%h|%s|%an|%ai|%stat",
        ]

        if since:
            args.append(f"--since={since}")
        if file:
            args.extend(["--", file])

        _, output, _ = await self._run_git(*args)

        commits = []
        for line in output.split("\n"):
            if not line or "|" not in line:
                continue

            parts = line.split("|")
            if len(parts) >= 5:
                commits.append(
                    GitCommit(
                        hash=parts[0],
                        short_hash=parts[1],
                        message=parts[2],
                        author=parts[3],
                        date=parts[4],
                    )
                )

        return commits

    async def add(self, files: Optional[list[str]] = None) -> GitResult:
        """
        Stage files for commit.

        Args:
            files: List of files to stage, or None for all

        Returns:
            GitResult with operation status
        """
        if files:
            args = ["add", "--"] + files
        else:
            args = ["add", "-A"]

        returncode, output, error = await self._run_git(*args)

        return GitResult(
            success=returncode == 0,
            output=output,
            error=error if returncode != 0 else None,
        )

    async def commit(
        self,
        message: str,
        files: Optional[list[str]] = None,
        amend: bool = False,
    ) -> GitResult:
        """
        Create a commit.

        Args:
            message: Commit message
            files: Optional specific files to commit (will be staged first)
            amend: If True, amend the previous commit

        Returns:
            GitResult with commit hash on success
        """
        # Stage files if specified
        if files:
            add_result = await self.add(files)
            if not add_result.success:
                return add_result

        # Build commit command
        args = ["commit", "-m", message]
        if amend:
            args.append("--amend")

        returncode, output, error = await self._run_git(*args)

        # Extract commit hash from output
        commit_hash = None
        if returncode == 0:
            # Output typically contains "[branch hash] message"
            match = re.search(r"\[[\w/-]+ ([a-f0-9]+)\]", output)
            if match:
                commit_hash = match.group(1)

        return GitResult(
            success=returncode == 0,
            output=output,
            error=error if returncode != 0 else None,
            commit_hash=commit_hash,
        )

    async def push(
        self,
        remote: str = "origin",
        branch: Optional[str] = None,
        force: bool = False,
        set_upstream: bool = False,
    ) -> GitResult:
        """
        Push commits to remote.

        Args:
            remote: Remote name (default: origin)
            branch: Branch name (default: current branch)
            force: Force push
            set_upstream: Set upstream tracking

        Returns:
            GitResult with operation status
        """
        args = ["push", remote]

        if branch:
            args.append(branch)
        if force:
            args.append("--force")
        if set_upstream:
            args.append("--set-upstream")

        returncode, output, error = await self._run_git(*args, timeout=60.0)

        return GitResult(
            success=returncode == 0,
            output=output or error,  # Git often puts push info in stderr
            error=error if returncode != 0 else None,
        )

    async def pull(
        self,
        remote: str = "origin",
        branch: Optional[str] = None,
        rebase: bool = False,
    ) -> GitResult:
        """
        Pull changes from remote.

        Args:
            remote: Remote name (default: origin)
            branch: Branch name (default: current branch)
            rebase: Use rebase instead of merge

        Returns:
            GitResult with operation status
        """
        args = ["pull", remote]

        if branch:
            args.append(branch)
        if rebase:
            args.append("--rebase")

        returncode, output, error = await self._run_git(*args, timeout=60.0)

        return GitResult(
            success=returncode == 0,
            output=output,
            error=error if returncode != 0 else None,
        )

    async def fetch(self, remote: str = "origin") -> GitResult:
        """
        Fetch changes from remote without merging.

        Args:
            remote: Remote name (default: origin)

        Returns:
            GitResult with operation status
        """
        returncode, output, error = await self._run_git("fetch", remote, timeout=60.0)

        return GitResult(
            success=returncode == 0,
            output=output,
            error=error if returncode != 0 else None,
        )

    async def checkout(
        self,
        branch: str,
        create: bool = False,
    ) -> GitResult:
        """
        Checkout a branch.

        Args:
            branch: Branch name
            create: Create the branch if it doesn't exist

        Returns:
            GitResult with operation status
        """
        args = ["checkout"]
        if create:
            args.append("-b")
        args.append(branch)

        returncode, output, error = await self._run_git(*args)

        return GitResult(
            success=returncode == 0,
            output=output,
            error=error if returncode != 0 else None,
        )

    async def create_branch(self, name: str, start_point: Optional[str] = None) -> GitResult:
        """
        Create a new branch.

        Args:
            name: Branch name
            start_point: Optional starting commit/branch

        Returns:
            GitResult with operation status
        """
        args = ["branch", name]
        if start_point:
            args.append(start_point)

        returncode, output, error = await self._run_git(*args)

        return GitResult(
            success=returncode == 0,
            output=output,
            error=error if returncode != 0 else None,
        )

    async def stash(self, message: Optional[str] = None) -> GitResult:
        """
        Stash current changes.

        Args:
            message: Optional stash message

        Returns:
            GitResult with operation status
        """
        args = ["stash", "push"]
        if message:
            args.extend(["-m", message])

        returncode, output, error = await self._run_git(*args)

        return GitResult(
            success=returncode == 0,
            output=output,
            error=error if returncode != 0 else None,
        )

    async def stash_pop(self) -> GitResult:
        """
        Pop the most recent stash.

        Returns:
            GitResult with operation status
        """
        returncode, output, error = await self._run_git("stash", "pop")

        return GitResult(
            success=returncode == 0,
            output=output,
            error=error if returncode != 0 else None,
        )

    async def reset(
        self,
        mode: str = "mixed",
        commit: str = "HEAD",
    ) -> GitResult:
        """
        Reset repository state.

        Args:
            mode: Reset mode (soft, mixed, hard)
            commit: Target commit (default: HEAD)

        Returns:
            GitResult with operation status
        """
        args = ["reset", f"--{mode}", commit]
        returncode, output, error = await self._run_git(*args)

        return GitResult(
            success=returncode == 0,
            output=output,
            error=error if returncode != 0 else None,
        )

    async def get_changed_files_since(self, commit: str) -> list[str]:
        """
        Get list of files changed since a specific commit.

        Args:
            commit: Starting commit hash

        Returns:
            List of changed file paths
        """
        _, output, _ = await self._run_git(
            "diff", "--name-only", f"{commit}..HEAD"
        )

        return [f for f in output.split("\n") if f]

    async def get_file_at_commit(self, file: str, commit: str) -> str:
        """
        Get file contents at a specific commit.

        Args:
            file: File path
            commit: Commit hash

        Returns:
            File contents as string
        """
        _, output, _ = await self._run_git("show", f"{commit}:{file}")
        return output