painapple-code 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- painapple_code/__init__.py +30 -0
- painapple_code/__main__.py +18 -0
- painapple_code/_version.py +24 -0
- painapple_code/auth_middleware.py +930 -0
- painapple_code/cli/__init__.py +204 -0
- painapple_code/cli/deploy/__init__.py +15 -0
- painapple_code/cli/deploy/claude_seed.py +41 -0
- painapple_code/cli/deploy/config.py +260 -0
- painapple_code/cli/deploy/container.py +715 -0
- painapple_code/cli/deploy/launch.py +163 -0
- painapple_code/cli/deploy/runtime.py +223 -0
- painapple_code/cli/lifecycle_cmd.py +443 -0
- painapple_code/cli/list_cmd.py +572 -0
- painapple_code/cli/manage_cmd.py +670 -0
- painapple_code/cli/netinfo.py +125 -0
- painapple_code/cli/profiles.py +390 -0
- painapple_code/cli/serve_args.py +163 -0
- painapple_code/cli/serve_config.py +256 -0
- painapple_code/cli/setup_cmd.py +664 -0
- painapple_code/cli/ui.py +438 -0
- painapple_code/cost_analytics.py +879 -0
- painapple_code/data/models.defaults.yaml +18 -0
- painapple_code/data/models.yaml +18 -0
- painapple_code/data/presets.defaults.json +32 -0
- painapple_code/data/strings.yaml +2269 -0
- painapple_code/helpers.py +406 -0
- painapple_code/paths.py +1514 -0
- painapple_code/prompt_explorer.py +984 -0
- painapple_code/providers/__init__.py +224 -0
- painapple_code/providers/base.py +710 -0
- painapple_code/providers/claude/__init__.py +74 -0
- painapple_code/providers/claude/capabilities.py +218 -0
- painapple_code/providers/claude/errors.py +79 -0
- painapple_code/providers/claude/launch.py +50 -0
- painapple_code/providers/claude/summary.py +89 -0
- painapple_code/providers/claude/translate.py +74 -0
- painapple_code/providers/claude_sdk/__init__.py +139 -0
- painapple_code/providers/claude_sdk/driver.py +412 -0
- painapple_code/providers/codex/__init__.py +87 -0
- painapple_code/providers/codex/capabilities.py +321 -0
- painapple_code/providers/codex/errors.py +70 -0
- painapple_code/providers/codex/launch.py +142 -0
- painapple_code/providers/codex/summary.py +196 -0
- painapple_code/providers/codex/translate.py +204 -0
- painapple_code/providers/codex_app_server/__init__.py +100 -0
- painapple_code/providers/codex_app_server/capabilities.py +41 -0
- painapple_code/providers/codex_app_server/launch.py +202 -0
- painapple_code/providers/codex_app_server/summary.py +291 -0
- painapple_code/providers/codex_app_server/translate.py +263 -0
- painapple_code/providers/codex_app_server/transport.py +238 -0
- painapple_code/routes/__init__.py +0 -0
- painapple_code/routes/api_agents.py +441 -0
- painapple_code/routes/api_app.py +368 -0
- painapple_code/routes/api_app_commit_sections.py +127 -0
- painapple_code/routes/api_app_config.py +997 -0
- painapple_code/routes/api_app_session_prefs.py +217 -0
- painapple_code/routes/api_browser.py +690 -0
- painapple_code/routes/api_commands.py +374 -0
- painapple_code/routes/api_costs.py +128 -0
- painapple_code/routes/api_drafts.py +139 -0
- painapple_code/routes/api_exec.py +149 -0
- painapple_code/routes/api_files.py +542 -0
- painapple_code/routes/api_git.py +657 -0
- painapple_code/routes/api_logs.py +649 -0
- painapple_code/routes/api_plugins.py +275 -0
- painapple_code/routes/api_project_config.py +418 -0
- painapple_code/routes/api_prompts.py +231 -0
- painapple_code/routes/api_providers.py +53 -0
- painapple_code/routes/api_search.py +411 -0
- painapple_code/routes/api_session_stash.py +234 -0
- painapple_code/routes/api_session_welcome.py +151 -0
- painapple_code/routes/api_sessions.py +742 -0
- painapple_code/routes/api_shadow.py +323 -0
- painapple_code/routes/api_shadow_db.py +512 -0
- painapple_code/routes/api_shadow_files.py +332 -0
- painapple_code/routes/api_shadow_search.py +476 -0
- painapple_code/routes/api_skills.py +610 -0
- painapple_code/routes/api_tasks.py +147 -0
- painapple_code/routes/api_terminal.py +540 -0
- painapple_code/routes/api_upload.py +293 -0
- painapple_code/routes/api_viewer.py +229 -0
- painapple_code/routes/dependencies.py +109 -0
- painapple_code/routes/ws_chat.py +837 -0
- painapple_code/server.py +2208 -0
- painapple_code/server_logging.py +263 -0
- painapple_code/services/__init__.py +0 -0
- painapple_code/services/agent_session.py +2738 -0
- painapple_code/session_store.py +253 -0
- painapple_code/session_store_core.py +879 -0
- painapple_code/shadow_db.py +830 -0
- painapple_code/shadow_db_plans.py +369 -0
- painapple_code/shadow_db_queries.py +595 -0
- painapple_code/shadow_db_schema.py +676 -0
- painapple_code/shadow_git.py +1296 -0
- painapple_code/shadow_git_frontmatter.py +314 -0
- painapple_code/shadow_git_sections.py +520 -0
- painapple_code/shadow_git_summary.py +462 -0
- painapple_code/shadow_parser.py +560 -0
- painapple_code/static/css/00-variables.css +276 -0
- painapple_code/static/css/01-base.css +245 -0
- painapple_code/static/css/02-tooltips.css +36 -0
- painapple_code/static/css/09-left-rail.css +448 -0
- painapple_code/static/css/10-header.css +777 -0
- painapple_code/static/css/11-connection-bar.css +120 -0
- painapple_code/static/css/20-messages.css +750 -0
- painapple_code/static/css/21-tool-blocks.css +2340 -0
- painapple_code/static/css/22-edit-diff.css +619 -0
- painapple_code/static/css/23-thinking.css +1584 -0
- painapple_code/static/css/24-background-tasks.css +359 -0
- painapple_code/static/css/25-context-block.css +252 -0
- painapple_code/static/css/26-session-setup.css +144 -0
- painapple_code/static/css/30-markdown.css +854 -0
- painapple_code/static/css/40-input.css +2418 -0
- painapple_code/static/css/41-autocomplete.css +127 -0
- painapple_code/static/css/42-activity-strip.css +97 -0
- painapple_code/static/css/43-file-autocomplete.css +121 -0
- painapple_code/static/css/44-snippets-autocomplete.css +224 -0
- painapple_code/static/css/45-selection-bar.css +433 -0
- painapple_code/static/css/46-keyboard-bar.css +119 -0
- painapple_code/static/css/47-keyboard-longpress.css +116 -0
- painapple_code/static/css/47-token-profile.css +106 -0
- painapple_code/static/css/50-welcome.css +3095 -0
- painapple_code/static/css/51-modals.css +301 -0
- painapple_code/static/css/52-utilities.css +77 -0
- painapple_code/static/css/53-image-upload.css +456 -0
- painapple_code/static/css/54-todo-list.css +102 -0
- painapple_code/static/css/55-file-upload.css +113 -0
- painapple_code/static/css/56-chat-navigator.css +218 -0
- painapple_code/static/css/56-permission-card.css +332 -0
- painapple_code/static/css/56-plan-approval.css +204 -0
- painapple_code/static/css/56-question-form.css +602 -0
- painapple_code/static/css/57-swipe-indicator.css +95 -0
- painapple_code/static/css/58-session-families.css +627 -0
- painapple_code/static/css/59-about.css +196 -0
- painapple_code/static/css/61-log-explorer.css +723 -0
- painapple_code/static/css/62-json-tree.css +489 -0
- painapple_code/static/css/62-skills-widget.css +650 -0
- painapple_code/static/css/63-commands-widget.css +30 -0
- painapple_code/static/css/63-plugins-widget.css +174 -0
- painapple_code/static/css/63-terminal.css +516 -0
- painapple_code/static/css/64-search-files-widget.css +448 -0
- painapple_code/static/css/65-snippets-widget.css +426 -0
- painapple_code/static/css/66-active-sessions.css +979 -0
- painapple_code/static/css/66-agents-widget.css +265 -0
- painapple_code/static/css/66-file-explorer-widget.css +855 -0
- painapple_code/static/css/66-git-panel.css +791 -0
- painapple_code/static/css/66-history-explorer.css +1496 -0
- painapple_code/static/css/67-diff-viewer.css +602 -0
- painapple_code/static/css/67-editor-view.css +748 -0
- painapple_code/static/css/67-file-preview-widget.css +1873 -0
- painapple_code/static/css/68-browser-widget.css +337 -0
- painapple_code/static/css/68-compare-wizard.css +355 -0
- painapple_code/static/css/68-csv-preview.css +106 -0
- painapple_code/static/css/68-diff-preview.css +103 -0
- painapple_code/static/css/68-helpers-install-widget.css +491 -0
- painapple_code/static/css/68-image-annotate.css +346 -0
- painapple_code/static/css/68-terminal-view.css +98 -0
- painapple_code/static/css/68-uploads-widget.css +229 -0
- painapple_code/static/css/69-cost-analytics.css +537 -0
- painapple_code/static/css/70-config-panel.css +3736 -0
- painapple_code/static/css/70-excalidraw.css +136 -0
- painapple_code/static/css/70-prompt-explorer.css +890 -0
- painapple_code/static/css/71-chart.css +111 -0
- painapple_code/static/css/72-discussion-sidebar.css +678 -0
- painapple_code/static/css/74-debug-logs.css +656 -0
- painapple_code/static/css/80-open-dialog.css +146 -0
- painapple_code/static/css/80-quick-switcher.css +383 -0
- painapple_code/static/css/80-widget-system.css +1763 -0
- painapple_code/static/css/81-quick-actions.css +1020 -0
- painapple_code/static/css/82-context-popover.css +1233 -0
- painapple_code/static/css/83-context-menu.css +339 -0
- painapple_code/static/css/85-lazy-loading.css +102 -0
- painapple_code/static/css/86-zen-mode.css +708 -0
- painapple_code/static/css/87-grid-switcher.css +346 -0
- painapple_code/static/css/login.css +198 -0
- painapple_code/static/dist/app.bundle.js +7177 -0
- painapple_code/static/feature-triage.html +446 -0
- painapple_code/static/icons/apple-touch-icon.png +0 -0
- painapple_code/static/icons/favicon-32.png +0 -0
- painapple_code/static/icons/favicon.ico +0 -0
- painapple_code/static/icons/icon-128.png +0 -0
- painapple_code/static/icons/icon-144.png +0 -0
- painapple_code/static/icons/icon-152.png +0 -0
- painapple_code/static/icons/icon-180.png +0 -0
- painapple_code/static/icons/icon-192.png +0 -0
- painapple_code/static/icons/icon-384.png +0 -0
- painapple_code/static/icons/icon-512.png +0 -0
- painapple_code/static/icons/icon-72.png +0 -0
- painapple_code/static/icons/icon-96.png +0 -0
- painapple_code/static/js/action-delegate.js +187 -0
- painapple_code/static/js/activity-strip.js +193 -0
- painapple_code/static/js/app/context-menus.js +619 -0
- painapple_code/static/js/app/file-preview.js +163 -0
- painapple_code/static/js/app/init-ui.js +574 -0
- painapple_code/static/js/app/input-events.js +561 -0
- painapple_code/static/js/app/input-resize.js +313 -0
- painapple_code/static/js/app/message-actions.js +419 -0
- painapple_code/static/js/app/panels.js +210 -0
- painapple_code/static/js/app/rendering-delegators.js +246 -0
- painapple_code/static/js/app/server-loading.js +365 -0
- painapple_code/static/js/app/session-ops.js +287 -0
- painapple_code/static/js/app/session-switch.js +443 -0
- painapple_code/static/js/app/settings-helpers.js +280 -0
- painapple_code/static/js/app/startup-welcome.js +290 -0
- painapple_code/static/js/app-context.js +157 -0
- painapple_code/static/js/app.js +2134 -0
- painapple_code/static/js/auth-fetch.js +46 -0
- painapple_code/static/js/background-tasks.js +274 -0
- painapple_code/static/js/boot.js +73 -0
- painapple_code/static/js/caret-position.js +171 -0
- painapple_code/static/js/chat-navigator.js +710 -0
- painapple_code/static/js/chat-search.js +323 -0
- painapple_code/static/js/command-executor.js +176 -0
- painapple_code/static/js/command-store.js +497 -0
- painapple_code/static/js/components.js +1414 -0
- painapple_code/static/js/config.js +218 -0
- painapple_code/static/js/context-menu.js +740 -0
- painapple_code/static/js/controllers/chat-controller.js +4252 -0
- painapple_code/static/js/controllers/dialog-controller.js +217 -0
- painapple_code/static/js/controllers/tab-controller.js +1828 -0
- painapple_code/static/js/controllers/thinking-controller.js +915 -0
- painapple_code/static/js/diff-utils.js +486 -0
- painapple_code/static/js/drafts-pill.js +95 -0
- painapple_code/static/js/editor-view.js +815 -0
- painapple_code/static/js/effort-settings.js +430 -0
- painapple_code/static/js/feature-triage.js +811 -0
- painapple_code/static/js/file-autocomplete.js +516 -0
- painapple_code/static/js/file-tabs.js +123 -0
- painapple_code/static/js/gestures.js +448 -0
- painapple_code/static/js/grid-switcher.js +334 -0
- painapple_code/static/js/input-handler.js +1472 -0
- painapple_code/static/js/keyboard-bar.js +495 -0
- painapple_code/static/js/layout-switcher.js +109 -0
- painapple_code/static/js/linkify-utils.js +200 -0
- painapple_code/static/js/login.js +141 -0
- painapple_code/static/js/open-dialog.js +879 -0
- painapple_code/static/js/orphan-terminals.js +373 -0
- painapple_code/static/js/path-utils.js +179 -0
- painapple_code/static/js/perf-marks.js +102 -0
- painapple_code/static/js/permission-settings.js +475 -0
- painapple_code/static/js/pointer-input.js +59 -0
- painapple_code/static/js/preview/json-tree-menu.js +226 -0
- painapple_code/static/js/preview/json-tree-toolbar.js +291 -0
- painapple_code/static/js/preview/json-tree.js +457 -0
- painapple_code/static/js/preview/preview-breadcrumb.js +387 -0
- painapple_code/static/js/preview/preview-edit.js +367 -0
- painapple_code/static/js/preview/preview-events.js +361 -0
- painapple_code/static/js/preview/preview-history.js +567 -0
- painapple_code/static/js/preview/preview-inline-edit.js +869 -0
- painapple_code/static/js/preview/preview-poll.js +108 -0
- painapple_code/static/js/preview/preview-render.js +572 -0
- painapple_code/static/js/preview/preview-search.js +421 -0
- painapple_code/static/js/preview/preview-state.js +256 -0
- painapple_code/static/js/preview/preview-utils.js +251 -0
- painapple_code/static/js/preview-plugins/chart-plugin.js +22 -0
- painapple_code/static/js/preview-plugins/csv-plugin.js +188 -0
- painapple_code/static/js/preview-plugins/diff-plugin.js +258 -0
- painapple_code/static/js/preview-plugins/excalidraw-plugin.js +25 -0
- painapple_code/static/js/preview-plugins/html-plugin.js +104 -0
- painapple_code/static/js/preview-plugins/image-plugin.js +20 -0
- painapple_code/static/js/preview-plugins/index.js +34 -0
- painapple_code/static/js/preview-plugins/json-plugin.js +99 -0
- painapple_code/static/js/preview-plugins/jsonl-plugin.js +152 -0
- painapple_code/static/js/preview-plugins/markdown-plugin.js +266 -0
- painapple_code/static/js/preview-plugins/panzoom-plugin.js +63 -0
- painapple_code/static/js/preview-plugins/plugin-helpers.js +282 -0
- painapple_code/static/js/project-color-picker.js +110 -0
- painapple_code/static/js/project-colors.js +163 -0
- painapple_code/static/js/prompt-favorites.js +187 -0
- painapple_code/static/js/quick-actions-menu.js +1736 -0
- painapple_code/static/js/quick-actions-registry.js +1239 -0
- painapple_code/static/js/quick-switcher/controller.js +294 -0
- painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
- painapple_code/static/js/quick-switcher/index.js +30 -0
- painapple_code/static/js/quick-switcher/providers/base-provider.js +51 -0
- painapple_code/static/js/quick-switcher/providers/command-provider.js +56 -0
- painapple_code/static/js/quick-switcher/providers/file-provider.js +291 -0
- painapple_code/static/js/quick-switcher/providers/panel-provider.js +132 -0
- painapple_code/static/js/quick-switcher/providers/project-provider.js +361 -0
- painapple_code/static/js/quick-switcher/providers/read-files-provider.js +104 -0
- painapple_code/static/js/quick-switcher/providers/skills-provider.js +129 -0
- painapple_code/static/js/quick-switcher/registry.js +69 -0
- painapple_code/static/js/quick-switcher/ui/item.js +54 -0
- painapple_code/static/js/quick-switcher/ui/picker.js +464 -0
- painapple_code/static/js/recent-opens.js +40 -0
- painapple_code/static/js/scroll-manager.js +530 -0
- painapple_code/static/js/scroll-state-machine.js +359 -0
- painapple_code/static/js/selection/action-bar.js +793 -0
- painapple_code/static/js/selection/index.js +15 -0
- painapple_code/static/js/selection/selection-handler.js +877 -0
- painapple_code/static/js/selection/state.js +181 -0
- painapple_code/static/js/session/agent-progress.js +202 -0
- painapple_code/static/js/session/handle-agent-message.js +481 -0
- painapple_code/static/js/session/handle-message.js +550 -0
- painapple_code/static/js/session/interactive.js +587 -0
- painapple_code/static/js/session/message-store.js +271 -0
- painapple_code/static/js/session/messages.js +253 -0
- painapple_code/static/js/session/persistence.js +179 -0
- painapple_code/static/js/session/restore.js +136 -0
- painapple_code/static/js/session/sync.js +333 -0
- painapple_code/static/js/session-container-pool.js +371 -0
- painapple_code/static/js/session-setup-panel.js +274 -0
- painapple_code/static/js/session.js +1047 -0
- painapple_code/static/js/shortcut-hints.js +272 -0
- painapple_code/static/js/shortcuts.js +1146 -0
- painapple_code/static/js/skills-autocomplete.js +262 -0
- painapple_code/static/js/snippets-autocomplete.js +738 -0
- painapple_code/static/js/stash-refs-view.js +83 -0
- painapple_code/static/js/stash-ui.js +768 -0
- painapple_code/static/js/stash.js +785 -0
- painapple_code/static/js/status-bar.js +1184 -0
- painapple_code/static/js/token-profile.js +274 -0
- painapple_code/static/js/tool-renderer-blocks.js +1626 -0
- painapple_code/static/js/tool-renderer-thinking.js +813 -0
- painapple_code/static/js/tool-renderer.js +1500 -0
- painapple_code/static/js/tooltips.js +197 -0
- painapple_code/static/js/upload-manager.js +1019 -0
- painapple_code/static/js/utils.js +521 -0
- painapple_code/static/js/welcome/api.js +363 -0
- painapple_code/static/js/welcome/cards.js +626 -0
- painapple_code/static/js/welcome/context-menu.js +585 -0
- painapple_code/static/js/welcome/families.js +801 -0
- painapple_code/static/js/welcome/preview.js +234 -0
- painapple_code/static/js/welcome/state.js +129 -0
- painapple_code/static/js/welcome.js +1874 -0
- painapple_code/static/js/widget-system/base-widget.js +717 -0
- painapple_code/static/js/widget-system/device-manager.js +178 -0
- painapple_code/static/js/widget-system/event-bus.js +152 -0
- painapple_code/static/js/widget-system/icons.js +120 -0
- painapple_code/static/js/widget-system/index.js +49 -0
- painapple_code/static/js/widget-system/init.js +54 -0
- painapple_code/static/js/widget-system/types/bottom-sheet.js +307 -0
- painapple_code/static/js/widget-system/types/floating.js +915 -0
- painapple_code/static/js/widget-system/types/index.js +40 -0
- painapple_code/static/js/widget-system/types/modal.js +147 -0
- painapple_code/static/js/widget-system/types/sidebar.js +158 -0
- painapple_code/static/js/widget-system/types/tab.js +146 -0
- painapple_code/static/js/widget-system/types/top-sheet.js +203 -0
- painapple_code/static/js/widget-system/widget-manager.js +1187 -0
- painapple_code/static/js/widgets/about-widget.js +260 -0
- painapple_code/static/js/widgets/active-sessions-widget.js +760 -0
- painapple_code/static/js/widgets/agents-widget.js +897 -0
- painapple_code/static/js/widgets/browser-widget.js +579 -0
- painapple_code/static/js/widgets/commands-widget.js +694 -0
- painapple_code/static/js/widgets/config/commit-sections.js +449 -0
- painapple_code/static/js/widgets/config/dir-autocomplete.js +224 -0
- painapple_code/static/js/widgets/config/gestures.js +161 -0
- painapple_code/static/js/widgets/config/models-tab.js +988 -0
- painapple_code/static/js/widgets/config/quick-actions-tab.js +882 -0
- painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
- painapple_code/static/js/widgets/config/state.js +517 -0
- painapple_code/static/js/widgets/config/system-controls.js +90 -0
- painapple_code/static/js/widgets/config-widget.js +1535 -0
- painapple_code/static/js/widgets/cost-analytics-widget.js +621 -0
- painapple_code/static/js/widgets/debug-widget.js +693 -0
- painapple_code/static/js/widgets/diff-viewer-widget.js +1420 -0
- painapple_code/static/js/widgets/discussion-widget.js +1107 -0
- painapple_code/static/js/widgets/file-explorer-widget.js +2166 -0
- painapple_code/static/js/widgets/file-preview-widget.js +861 -0
- painapple_code/static/js/widgets/git-widget.js +794 -0
- painapple_code/static/js/widgets/helpers-install-widget.js +720 -0
- painapple_code/static/js/widgets/history-explorer-widget.js +1804 -0
- painapple_code/static/js/widgets/image-annotate-widget.js +951 -0
- painapple_code/static/js/widgets/image-preview-widget.js +692 -0
- painapple_code/static/js/widgets/index.js +176 -0
- painapple_code/static/js/widgets/log-explorer-widget.js +1057 -0
- painapple_code/static/js/widgets/plugins-widget.js +305 -0
- painapple_code/static/js/widgets/prompt-explorer-widget.js +1119 -0
- painapple_code/static/js/widgets/search-files-widget.js +529 -0
- painapple_code/static/js/widgets/skills-widget.js +843 -0
- painapple_code/static/js/widgets/snippets-widget.js +479 -0
- painapple_code/static/js/widgets/sub-agents-widget.js +333 -0
- painapple_code/static/js/widgets/tasks-widget.js +379 -0
- painapple_code/static/js/widgets/terminal/connection.js +283 -0
- painapple_code/static/js/widgets/terminal/gestures.js +357 -0
- painapple_code/static/js/widgets/terminal/init.js +642 -0
- painapple_code/static/js/widgets/terminal/link-providers.js +532 -0
- painapple_code/static/js/widgets/terminal/osc52.js +213 -0
- painapple_code/static/js/widgets/terminal/render.js +159 -0
- painapple_code/static/js/widgets/terminal/size.js +54 -0
- painapple_code/static/js/widgets/terminal/state.js +211 -0
- painapple_code/static/js/widgets/terminal/wrap-utils.js +101 -0
- painapple_code/static/js/widgets/terminal-widget.js +793 -0
- painapple_code/static/js/widgets/uploads-widget.js +461 -0
- painapple_code/static/js/widgets/zen-widget.js +1001 -0
- painapple_code/static/login.html +51 -0
- painapple_code/static/manifest.json +73 -0
- painapple_code/static/sw.js +293 -0
- painapple_code/static/vendor/LICENSES.txt +769 -0
- painapple_code/static/vendor/README.md +103 -0
- painapple_code/static/vendor/codemirror.js +35 -0
- painapple_code/static/vendor/codemirror.js.LEGAL.txt +304 -0
- painapple_code/static/vendor/github-markdown-dark.min.css +1124 -0
- painapple_code/static/vendor/highlight-github-dark.min.css +10 -0
- painapple_code/static/vendor/highlight-lang-dockerfile.min.js +8 -0
- painapple_code/static/vendor/highlight-lang-nginx.min.js +21 -0
- painapple_code/static/vendor/highlight-lang-properties.min.js +10 -0
- painapple_code/static/vendor/highlight-lang-scala.min.js +28 -0
- painapple_code/static/vendor/highlight.min.js +1213 -0
- painapple_code/static/vendor/marked.min.js +6 -0
- painapple_code/static/vendor/purify.min.js +3 -0
- painapple_code/static/vendor/xterm-addon-fit.js +2 -0
- painapple_code/static/vendor/xterm.css +209 -0
- painapple_code/static/vendor/xterm.js +2 -0
- painapple_code/static/web-client.html +635 -0
- painapple_code/subprocess_registry.py +535 -0
- painapple_code/tls_cert.py +79 -0
- painapple_code/tools/agents/shadow-git-helper.md +400 -0
- painapple_code/tools/excalidraw-to-svg.js +143 -0
- painapple_code/tools/install-helpers.sh +148 -0
- painapple_code/tools/shadow-git +320 -0
- painapple_code/tools/shadow-query +120 -0
- painapple_code/tools/vegalite-to-svg.js +79 -0
- painapple_code/turn_query.py +766 -0
- painapple_code/turn_tracker.py +221 -0
- painapple_code/utils/__init__.py +0 -0
- painapple_code/utils/agent_cli.py +306 -0
- painapple_code/utils/chart.py +103 -0
- painapple_code/utils/excalidraw.py +107 -0
- painapple_code/utils/file_lock.py +162 -0
- painapple_code/utils/file_paths.py +648 -0
- painapple_code/utils/generate_icons.py +251 -0
- painapple_code/utils/proc.py +205 -0
- painapple_code/utils/pty_backend.py +59 -0
- painapple_code/utils/pty_posix.py +160 -0
- painapple_code/utils/pty_windows.py +245 -0
- painapple_code/utils/token_profiles.py +91 -0
- painapple_code/viewer_templates.py +657 -0
- painapple_code/welcome_search.py +879 -0
- painapple_code-1.0.0.dist-info/METADATA +292 -0
- painapple_code-1.0.0.dist-info/RECORD +436 -0
- painapple_code-1.0.0.dist-info/WHEEL +5 -0
- painapple_code-1.0.0.dist-info/entry_points.txt +2 -0
- painapple_code-1.0.0.dist-info/licenses/LICENSE +661 -0
- painapple_code-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +261 -0
- painapple_code-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git API Routes - Read-only git repository operations
|
|
3
|
+
|
|
4
|
+
These endpoints provide read-only access to git repositories for
|
|
5
|
+
status, diff, log, and commit viewing functionality.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from fastapi import APIRouter, HTTPException
|
|
13
|
+
|
|
14
|
+
from painapple_code.utils.file_paths import resolve_work_dir
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
router = APIRouter(prefix="/api/git", tags=["git"])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def _run_git(cmd: list[str], cwd: str, timeout: float = 30) -> tuple[str, int]:
|
|
22
|
+
"""Run a git command asynchronously. Returns (stdout, returncode)."""
|
|
23
|
+
proc = await asyncio.create_subprocess_exec(
|
|
24
|
+
*cmd, cwd=cwd,
|
|
25
|
+
stdout=asyncio.subprocess.PIPE,
|
|
26
|
+
stderr=asyncio.subprocess.PIPE,
|
|
27
|
+
)
|
|
28
|
+
try:
|
|
29
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
30
|
+
except asyncio.TimeoutError:
|
|
31
|
+
proc.kill()
|
|
32
|
+
await proc.communicate()
|
|
33
|
+
raise
|
|
34
|
+
return stdout.decode(), proc.returncode
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
38
|
+
# Git Status
|
|
39
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
40
|
+
|
|
41
|
+
@router.get("/status")
|
|
42
|
+
async def get_git_status(cwd: str = None):
|
|
43
|
+
"""
|
|
44
|
+
Get git status with structured output.
|
|
45
|
+
Returns staged, modified, and untracked files with stats.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
work_dir = resolve_work_dir(cwd)
|
|
49
|
+
if not work_dir.exists():
|
|
50
|
+
raise HTTPException(status_code=404, detail="Directory not found")
|
|
51
|
+
|
|
52
|
+
# Check if inside a git repo (works for subdirectories of git repos)
|
|
53
|
+
stdout, returncode = await _run_git(
|
|
54
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
55
|
+
str(work_dir), timeout=5
|
|
56
|
+
)
|
|
57
|
+
if returncode != 0:
|
|
58
|
+
return {"error": "not_a_repo", "message": "Not a git repository"}
|
|
59
|
+
|
|
60
|
+
# Get repo root so clients can resolve relative porcelain paths to absolute
|
|
61
|
+
stdout, returncode = await _run_git(
|
|
62
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
63
|
+
str(work_dir), timeout=5
|
|
64
|
+
)
|
|
65
|
+
root = stdout.strip() if returncode == 0 else str(work_dir)
|
|
66
|
+
|
|
67
|
+
# Get current branch
|
|
68
|
+
stdout, returncode = await _run_git(
|
|
69
|
+
["git", "branch", "--show-current"],
|
|
70
|
+
str(work_dir), timeout=10
|
|
71
|
+
)
|
|
72
|
+
branch = stdout.strip() or "HEAD detached"
|
|
73
|
+
|
|
74
|
+
# Get status with porcelain format for easy parsing
|
|
75
|
+
stdout, returncode = await _run_git(
|
|
76
|
+
["git", "status", "--porcelain=v1", "-uall"],
|
|
77
|
+
str(work_dir), timeout=30
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
staged = []
|
|
81
|
+
modified = []
|
|
82
|
+
untracked = []
|
|
83
|
+
|
|
84
|
+
for line in stdout.split('\n'):
|
|
85
|
+
line = line.rstrip() # Only strip trailing whitespace, preserve leading space
|
|
86
|
+
if not line:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
index_status = line[0] # Status in index (staged)
|
|
90
|
+
worktree_status = line[1] # Status in worktree
|
|
91
|
+
file_path = line[3:]
|
|
92
|
+
|
|
93
|
+
# Handle renamed files: "R old -> new"
|
|
94
|
+
if ' -> ' in file_path:
|
|
95
|
+
file_path = file_path.split(' -> ')[1]
|
|
96
|
+
|
|
97
|
+
file_info = {
|
|
98
|
+
"path": file_path,
|
|
99
|
+
"name": file_path.split('/')[-1],
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# Untracked files
|
|
103
|
+
if index_status == '?' and worktree_status == '?':
|
|
104
|
+
untracked.append(file_info)
|
|
105
|
+
# Staged changes
|
|
106
|
+
elif index_status in 'MADRC':
|
|
107
|
+
file_info["status"] = {
|
|
108
|
+
'M': 'modified',
|
|
109
|
+
'A': 'added',
|
|
110
|
+
'D': 'deleted',
|
|
111
|
+
'R': 'renamed',
|
|
112
|
+
'C': 'copied'
|
|
113
|
+
}.get(index_status, 'modified')
|
|
114
|
+
staged.append(file_info)
|
|
115
|
+
# Also modified in worktree?
|
|
116
|
+
if worktree_status == 'M':
|
|
117
|
+
modified.append({**file_info, "status": "modified"})
|
|
118
|
+
# Modified but not staged
|
|
119
|
+
elif worktree_status == 'M':
|
|
120
|
+
file_info["status"] = "modified"
|
|
121
|
+
modified.append(file_info)
|
|
122
|
+
elif worktree_status == 'D':
|
|
123
|
+
file_info["status"] = "deleted"
|
|
124
|
+
modified.append(file_info)
|
|
125
|
+
|
|
126
|
+
# Get ahead/behind info
|
|
127
|
+
stdout, returncode = await _run_git(
|
|
128
|
+
["git", "rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
|
|
129
|
+
str(work_dir), timeout=10
|
|
130
|
+
)
|
|
131
|
+
ahead = behind = 0
|
|
132
|
+
if returncode == 0 and stdout.strip():
|
|
133
|
+
parts = stdout.strip().split()
|
|
134
|
+
if len(parts) == 2:
|
|
135
|
+
behind, ahead = int(parts[0]), int(parts[1])
|
|
136
|
+
|
|
137
|
+
# Get diff stats for staged files
|
|
138
|
+
staged_stats = {}
|
|
139
|
+
if staged:
|
|
140
|
+
stdout, returncode = await _run_git(
|
|
141
|
+
["git", "diff", "--numstat", "--staged"],
|
|
142
|
+
str(work_dir), timeout=30
|
|
143
|
+
)
|
|
144
|
+
for line in stdout.strip().split('\n'):
|
|
145
|
+
if not line:
|
|
146
|
+
continue
|
|
147
|
+
parts = line.split('\t')
|
|
148
|
+
if len(parts) >= 3:
|
|
149
|
+
add, rm, path = parts[0], parts[1], parts[2]
|
|
150
|
+
# Handle binary files (shows '-' instead of numbers)
|
|
151
|
+
staged_stats[path] = {
|
|
152
|
+
'additions': int(add) if add != '-' else 0,
|
|
153
|
+
'deletions': int(rm) if rm != '-' else 0
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# Get diff stats for modified (unstaged) files
|
|
157
|
+
modified_stats = {}
|
|
158
|
+
if modified:
|
|
159
|
+
stdout, returncode = await _run_git(
|
|
160
|
+
["git", "diff", "--numstat"],
|
|
161
|
+
str(work_dir), timeout=30
|
|
162
|
+
)
|
|
163
|
+
for line in stdout.strip().split('\n'):
|
|
164
|
+
if not line:
|
|
165
|
+
continue
|
|
166
|
+
parts = line.split('\t')
|
|
167
|
+
if len(parts) >= 3:
|
|
168
|
+
add, rm, path = parts[0], parts[1], parts[2]
|
|
169
|
+
modified_stats[path] = {
|
|
170
|
+
'additions': int(add) if add != '-' else 0,
|
|
171
|
+
'deletions': int(rm) if rm != '-' else 0
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
# Merge stats into file info
|
|
175
|
+
for f in staged:
|
|
176
|
+
stats = staged_stats.get(f['path'], {})
|
|
177
|
+
f['additions'] = stats.get('additions', 0)
|
|
178
|
+
f['deletions'] = stats.get('deletions', 0)
|
|
179
|
+
|
|
180
|
+
for f in modified:
|
|
181
|
+
stats = modified_stats.get(f['path'], {})
|
|
182
|
+
f['additions'] = stats.get('additions', 0)
|
|
183
|
+
f['deletions'] = stats.get('deletions', 0)
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
"branch": branch,
|
|
187
|
+
"root": root,
|
|
188
|
+
"staged": staged,
|
|
189
|
+
"modified": modified,
|
|
190
|
+
"untracked": untracked,
|
|
191
|
+
"ahead": ahead,
|
|
192
|
+
"behind": behind,
|
|
193
|
+
"summary": {
|
|
194
|
+
"stagedCount": len(staged),
|
|
195
|
+
"modifiedCount": len(modified),
|
|
196
|
+
"untrackedCount": len(untracked),
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
except asyncio.TimeoutError:
|
|
201
|
+
raise HTTPException(status_code=504, detail="Git command timed out")
|
|
202
|
+
except Exception as e:
|
|
203
|
+
logger.error(f"Git status error: {e}")
|
|
204
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
208
|
+
# Git Diff
|
|
209
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
210
|
+
|
|
211
|
+
@router.get("/diff")
|
|
212
|
+
async def get_git_diff(cwd: str = None, file: str = None, staged: bool = False):
|
|
213
|
+
"""
|
|
214
|
+
Get git diff for a file or all changes.
|
|
215
|
+
- file: specific file path (optional)
|
|
216
|
+
- staged: if true, show staged changes (--cached)
|
|
217
|
+
"""
|
|
218
|
+
try:
|
|
219
|
+
work_dir = resolve_work_dir(cwd)
|
|
220
|
+
|
|
221
|
+
cmd = ["git", "diff", "--no-color"]
|
|
222
|
+
if staged:
|
|
223
|
+
cmd.append("--cached")
|
|
224
|
+
if file:
|
|
225
|
+
cmd.append("--")
|
|
226
|
+
cmd.append(file)
|
|
227
|
+
|
|
228
|
+
stdout, returncode = await _run_git(cmd, str(work_dir), timeout=30)
|
|
229
|
+
|
|
230
|
+
# Parse diff into structured format
|
|
231
|
+
diff_text = stdout
|
|
232
|
+
files = []
|
|
233
|
+
current_file = None
|
|
234
|
+
|
|
235
|
+
for line in diff_text.split('\n'):
|
|
236
|
+
if line.startswith('diff --git'):
|
|
237
|
+
if current_file:
|
|
238
|
+
files.append(current_file)
|
|
239
|
+
# Extract file path from "diff --git a/path b/path"
|
|
240
|
+
parts = line.split(' b/')
|
|
241
|
+
file_path = parts[-1] if len(parts) > 1 else ''
|
|
242
|
+
current_file = {
|
|
243
|
+
"path": file_path,
|
|
244
|
+
"hunks": [],
|
|
245
|
+
"additions": 0,
|
|
246
|
+
"deletions": 0,
|
|
247
|
+
}
|
|
248
|
+
elif line.startswith('@@') and current_file:
|
|
249
|
+
current_file["hunks"].append({
|
|
250
|
+
"header": line,
|
|
251
|
+
"lines": []
|
|
252
|
+
})
|
|
253
|
+
elif current_file and current_file["hunks"]:
|
|
254
|
+
hunk = current_file["hunks"][-1]
|
|
255
|
+
hunk["lines"].append(line)
|
|
256
|
+
if line.startswith('+') and not line.startswith('+++'):
|
|
257
|
+
current_file["additions"] += 1
|
|
258
|
+
elif line.startswith('-') and not line.startswith('---'):
|
|
259
|
+
current_file["deletions"] += 1
|
|
260
|
+
|
|
261
|
+
if current_file:
|
|
262
|
+
files.append(current_file)
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
"files": files,
|
|
266
|
+
"raw": diff_text,
|
|
267
|
+
"summary": {
|
|
268
|
+
"filesChanged": len(files),
|
|
269
|
+
"additions": sum(f["additions"] for f in files),
|
|
270
|
+
"deletions": sum(f["deletions"] for f in files),
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
except asyncio.TimeoutError:
|
|
275
|
+
raise HTTPException(status_code=504, detail="Git command timed out")
|
|
276
|
+
except Exception as e:
|
|
277
|
+
logger.error(f"Git diff error: {e}")
|
|
278
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
282
|
+
# Git File Content (for side-by-side diff viewer)
|
|
283
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
284
|
+
|
|
285
|
+
@router.get("/file-content")
|
|
286
|
+
async def get_git_file_content(file: str, cwd: str = None, staged: bool = False, commit: str = None):
|
|
287
|
+
"""
|
|
288
|
+
Get old/new versions of a file for side-by-side diff.
|
|
289
|
+
If commit is provided, compares commit~1 vs commit.
|
|
290
|
+
Otherwise compares HEAD vs working/staged.
|
|
291
|
+
"""
|
|
292
|
+
try:
|
|
293
|
+
work_dir = resolve_work_dir(cwd)
|
|
294
|
+
|
|
295
|
+
if commit:
|
|
296
|
+
# Validate commit hash
|
|
297
|
+
if not re.match(r'^[0-9a-fA-F]{4,40}$', commit):
|
|
298
|
+
raise HTTPException(status_code=400, detail="Invalid commit hash")
|
|
299
|
+
# Compare parent vs commit
|
|
300
|
+
old_stdout, old_rc = await _run_git(
|
|
301
|
+
["git", "show", f"{commit}~1:{file}"], str(work_dir), timeout=10
|
|
302
|
+
)
|
|
303
|
+
old_content = old_stdout if old_rc == 0 else ''
|
|
304
|
+
new_stdout, new_rc = await _run_git(
|
|
305
|
+
["git", "show", f"{commit}:{file}"], str(work_dir), timeout=10
|
|
306
|
+
)
|
|
307
|
+
new_content = new_stdout if new_rc == 0 else ''
|
|
308
|
+
old_label = f"{commit[:8]}~1"
|
|
309
|
+
new_label = commit[:8]
|
|
310
|
+
else:
|
|
311
|
+
# Get HEAD version
|
|
312
|
+
old_stdout, old_rc = await _run_git(
|
|
313
|
+
["git", "show", f"HEAD:{file}"], str(work_dir), timeout=10
|
|
314
|
+
)
|
|
315
|
+
old_content = old_stdout if old_rc == 0 else ''
|
|
316
|
+
|
|
317
|
+
if staged:
|
|
318
|
+
# Get staged (index) version
|
|
319
|
+
new_stdout, new_rc = await _run_git(
|
|
320
|
+
["git", "show", f":{file}"], str(work_dir), timeout=10
|
|
321
|
+
)
|
|
322
|
+
new_content = new_stdout if new_rc == 0 else ''
|
|
323
|
+
else:
|
|
324
|
+
# Read working tree version — confine to work_dir (no ../ escape)
|
|
325
|
+
import pathlib
|
|
326
|
+
work_root = pathlib.Path(str(work_dir)).resolve()
|
|
327
|
+
file_path = (work_root / file).resolve()
|
|
328
|
+
if not file_path.is_relative_to(work_root):
|
|
329
|
+
raise HTTPException(status_code=400, detail="File path escapes working directory")
|
|
330
|
+
try:
|
|
331
|
+
new_content = file_path.read_text(encoding="utf-8", errors='replace')
|
|
332
|
+
except FileNotFoundError:
|
|
333
|
+
new_content = ''
|
|
334
|
+
old_label = "HEAD"
|
|
335
|
+
new_label = "Staged" if staged else "Working Tree"
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
"file": file,
|
|
339
|
+
"old": old_content,
|
|
340
|
+
"new": new_content,
|
|
341
|
+
"oldLabel": old_label,
|
|
342
|
+
"newLabel": new_label
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
except asyncio.TimeoutError:
|
|
346
|
+
raise HTTPException(status_code=504, detail="Git command timed out")
|
|
347
|
+
except HTTPException:
|
|
348
|
+
raise
|
|
349
|
+
except Exception as e:
|
|
350
|
+
logger.error(f"Git file-content error: {e}")
|
|
351
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
355
|
+
# Git Log
|
|
356
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
357
|
+
|
|
358
|
+
@router.get("/log")
|
|
359
|
+
async def get_git_log(cwd: str = None, limit: int = 20, skip: int = 0):
|
|
360
|
+
"""
|
|
361
|
+
Get recent commit history.
|
|
362
|
+
"""
|
|
363
|
+
try:
|
|
364
|
+
work_dir = resolve_work_dir(cwd)
|
|
365
|
+
|
|
366
|
+
# Get commits with detailed format
|
|
367
|
+
stdout, returncode = await _run_git(
|
|
368
|
+
[
|
|
369
|
+
"git", "log",
|
|
370
|
+
f"--skip={skip}",
|
|
371
|
+
f"-{limit}",
|
|
372
|
+
"--format=%H%n%h%n%an%n%ae%n%at%n%s%n%b%n---COMMIT_END---"
|
|
373
|
+
],
|
|
374
|
+
str(work_dir), timeout=30
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
commits = []
|
|
378
|
+
entries = stdout.split('---COMMIT_END---')
|
|
379
|
+
|
|
380
|
+
for entry in entries:
|
|
381
|
+
lines = entry.strip().split('\n')
|
|
382
|
+
if len(lines) >= 6:
|
|
383
|
+
hash_full = lines[0]
|
|
384
|
+
hash_short = lines[1]
|
|
385
|
+
author_name = lines[2]
|
|
386
|
+
author_email = lines[3]
|
|
387
|
+
timestamp = int(lines[4]) if lines[4].isdigit() else 0
|
|
388
|
+
subject = lines[5]
|
|
389
|
+
body = '\n'.join(lines[6:]).strip() if len(lines) > 6 else ''
|
|
390
|
+
|
|
391
|
+
commits.append({
|
|
392
|
+
"hash": hash_full,
|
|
393
|
+
"hashShort": hash_short,
|
|
394
|
+
"author": author_name,
|
|
395
|
+
"email": author_email,
|
|
396
|
+
"timestamp": timestamp,
|
|
397
|
+
"date": datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat() if timestamp else None,
|
|
398
|
+
"subject": subject,
|
|
399
|
+
"body": body,
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
# Get total commit count
|
|
403
|
+
count_stdout, count_returncode = await _run_git(
|
|
404
|
+
["git", "rev-list", "--count", "HEAD"],
|
|
405
|
+
str(work_dir), timeout=10
|
|
406
|
+
)
|
|
407
|
+
total = int(count_stdout.strip()) if count_returncode == 0 else len(commits)
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
"commits": commits,
|
|
411
|
+
"total": total,
|
|
412
|
+
"limit": limit,
|
|
413
|
+
"skip": skip,
|
|
414
|
+
"hasMore": skip + len(commits) < total,
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
except asyncio.TimeoutError:
|
|
418
|
+
raise HTTPException(status_code=504, detail="Git command timed out")
|
|
419
|
+
except Exception as e:
|
|
420
|
+
logger.error(f"Git log error: {e}")
|
|
421
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
425
|
+
# Git Show (Commit Details)
|
|
426
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
427
|
+
|
|
428
|
+
@router.get("/show/{commit_hash}")
|
|
429
|
+
async def get_git_show(commit_hash: str, cwd: str = None):
|
|
430
|
+
"""
|
|
431
|
+
Get details and diff for a specific commit.
|
|
432
|
+
"""
|
|
433
|
+
try:
|
|
434
|
+
work_dir = resolve_work_dir(cwd)
|
|
435
|
+
|
|
436
|
+
# Validate commit hash (prevent injection)
|
|
437
|
+
if not re.match(r'^[a-fA-F0-9]{7,40}$', commit_hash):
|
|
438
|
+
raise HTTPException(status_code=400, detail="Invalid commit hash")
|
|
439
|
+
|
|
440
|
+
# Get commit details
|
|
441
|
+
stdout, returncode = await _run_git(
|
|
442
|
+
[
|
|
443
|
+
"git", "show",
|
|
444
|
+
"--format=%H%n%h%n%an%n%ae%n%at%n%s%n%b%n---DETAILS_END---",
|
|
445
|
+
"--stat",
|
|
446
|
+
"--no-color",
|
|
447
|
+
commit_hash
|
|
448
|
+
],
|
|
449
|
+
str(work_dir), timeout=30
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
if returncode != 0:
|
|
453
|
+
raise HTTPException(status_code=404, detail="Commit not found")
|
|
454
|
+
|
|
455
|
+
# Parse output
|
|
456
|
+
parts = stdout.split('---DETAILS_END---')
|
|
457
|
+
details_lines = parts[0].strip().split('\n')
|
|
458
|
+
stats = parts[1].strip() if len(parts) > 1 else ''
|
|
459
|
+
|
|
460
|
+
commit = {
|
|
461
|
+
"hash": details_lines[0] if len(details_lines) > 0 else '',
|
|
462
|
+
"hashShort": details_lines[1] if len(details_lines) > 1 else '',
|
|
463
|
+
"author": details_lines[2] if len(details_lines) > 2 else '',
|
|
464
|
+
"email": details_lines[3] if len(details_lines) > 3 else '',
|
|
465
|
+
"timestamp": int(details_lines[4]) if len(details_lines) > 4 and details_lines[4].isdigit() else 0,
|
|
466
|
+
"subject": details_lines[5] if len(details_lines) > 5 else '',
|
|
467
|
+
"body": '\n'.join(details_lines[6:]).strip() if len(details_lines) > 6 else '',
|
|
468
|
+
"stats": stats,
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
# Get the diff
|
|
472
|
+
diff_stdout, diff_returncode = await _run_git(
|
|
473
|
+
["git", "show", "--no-color", "--format=", commit_hash],
|
|
474
|
+
str(work_dir), timeout=30
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
# Parse diff
|
|
478
|
+
files = []
|
|
479
|
+
current_file = None
|
|
480
|
+
for line in diff_stdout.split('\n'):
|
|
481
|
+
if line.startswith('diff --git'):
|
|
482
|
+
if current_file:
|
|
483
|
+
files.append(current_file)
|
|
484
|
+
parts = line.split(' b/')
|
|
485
|
+
file_path = parts[-1] if len(parts) > 1 else ''
|
|
486
|
+
current_file = {
|
|
487
|
+
"path": file_path,
|
|
488
|
+
"hunks": [],
|
|
489
|
+
"additions": 0,
|
|
490
|
+
"deletions": 0,
|
|
491
|
+
}
|
|
492
|
+
elif line.startswith('@@') and current_file:
|
|
493
|
+
current_file["hunks"].append({
|
|
494
|
+
"header": line,
|
|
495
|
+
"lines": []
|
|
496
|
+
})
|
|
497
|
+
elif current_file and current_file["hunks"]:
|
|
498
|
+
hunk = current_file["hunks"][-1]
|
|
499
|
+
hunk["lines"].append(line)
|
|
500
|
+
if line.startswith('+') and not line.startswith('+++'):
|
|
501
|
+
current_file["additions"] += 1
|
|
502
|
+
elif line.startswith('-') and not line.startswith('---'):
|
|
503
|
+
current_file["deletions"] += 1
|
|
504
|
+
|
|
505
|
+
if current_file:
|
|
506
|
+
files.append(current_file)
|
|
507
|
+
|
|
508
|
+
return {
|
|
509
|
+
"commit": commit,
|
|
510
|
+
"files": files,
|
|
511
|
+
"diffRaw": diff_stdout,
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
except HTTPException:
|
|
515
|
+
raise
|
|
516
|
+
except asyncio.TimeoutError:
|
|
517
|
+
raise HTTPException(status_code=504, detail="Git command timed out")
|
|
518
|
+
except Exception as e:
|
|
519
|
+
logger.error(f"Git show error: {e}")
|
|
520
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
524
|
+
# Git File at Ref (for compare wizard)
|
|
525
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
526
|
+
|
|
527
|
+
@router.get("/file-at-ref")
|
|
528
|
+
async def get_git_file_at_ref(file: str, ref: str, cwd: str = None):
|
|
529
|
+
"""
|
|
530
|
+
Get file content at a specific git ref (commit hash or branch name).
|
|
531
|
+
"""
|
|
532
|
+
try:
|
|
533
|
+
work_dir = resolve_work_dir(cwd)
|
|
534
|
+
|
|
535
|
+
# Validate ref: allow hex hashes, branch names, HEAD~N, tags.
|
|
536
|
+
# Forbid a leading '-'/'.' so the ref can't smuggle a git option.
|
|
537
|
+
if not re.match(r'^(?![-.])[a-zA-Z0-9_./-]+(?:~\d+)?(?:\^\d+)?$', ref):
|
|
538
|
+
raise HTTPException(status_code=400, detail="Invalid ref")
|
|
539
|
+
|
|
540
|
+
stdout, rc = await _run_git(
|
|
541
|
+
["git", "show", f"{ref}:{file}"], str(work_dir), timeout=10
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
if rc != 0:
|
|
545
|
+
raise HTTPException(status_code=404, detail="File not found at ref")
|
|
546
|
+
|
|
547
|
+
return {"content": stdout, "file": file, "ref": ref}
|
|
548
|
+
|
|
549
|
+
except HTTPException:
|
|
550
|
+
raise
|
|
551
|
+
except asyncio.TimeoutError:
|
|
552
|
+
raise HTTPException(status_code=504, detail="Git command timed out")
|
|
553
|
+
except Exception as e:
|
|
554
|
+
logger.error(f"Git file-at-ref error: {e}")
|
|
555
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
559
|
+
# Git File Log (commits touching a specific file)
|
|
560
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
561
|
+
|
|
562
|
+
@router.get("/file-log")
|
|
563
|
+
async def get_git_file_log(file: str, cwd: str = None, limit: int = 30):
|
|
564
|
+
"""
|
|
565
|
+
Get commit history for a specific file.
|
|
566
|
+
"""
|
|
567
|
+
try:
|
|
568
|
+
work_dir = resolve_work_dir(cwd)
|
|
569
|
+
|
|
570
|
+
stdout, returncode = await _run_git(
|
|
571
|
+
[
|
|
572
|
+
"git", "log",
|
|
573
|
+
f"-{limit}",
|
|
574
|
+
"--format=%H|%h|%an|%at|%s",
|
|
575
|
+
"--follow",
|
|
576
|
+
"--", file
|
|
577
|
+
],
|
|
578
|
+
str(work_dir), timeout=30
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
if returncode != 0 or not stdout.strip():
|
|
582
|
+
return {"commits": [], "total": 0, "file": file}
|
|
583
|
+
|
|
584
|
+
commits = []
|
|
585
|
+
for line in stdout.strip().split('\n'):
|
|
586
|
+
if not line:
|
|
587
|
+
continue
|
|
588
|
+
parts = line.split('|', 4)
|
|
589
|
+
if len(parts) >= 5:
|
|
590
|
+
commits.append({
|
|
591
|
+
"hash": parts[0],
|
|
592
|
+
"hashShort": parts[1],
|
|
593
|
+
"author": parts[2],
|
|
594
|
+
"timestamp": int(parts[3]) if parts[3].isdigit() else 0,
|
|
595
|
+
"subject": parts[4],
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
return {"commits": commits, "total": len(commits), "file": file}
|
|
599
|
+
|
|
600
|
+
except asyncio.TimeoutError:
|
|
601
|
+
raise HTTPException(status_code=504, detail="Git command timed out")
|
|
602
|
+
except Exception as e:
|
|
603
|
+
logger.error(f"Git file-log error: {e}")
|
|
604
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
608
|
+
# Git Branches
|
|
609
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
610
|
+
|
|
611
|
+
@router.get("/branches")
|
|
612
|
+
async def get_git_branches(cwd: str = None):
|
|
613
|
+
"""
|
|
614
|
+
List all local branches with latest commit info.
|
|
615
|
+
"""
|
|
616
|
+
try:
|
|
617
|
+
work_dir = resolve_work_dir(cwd)
|
|
618
|
+
|
|
619
|
+
# Get current branch
|
|
620
|
+
cur_stdout, _ = await _run_git(
|
|
621
|
+
["git", "branch", "--show-current"], str(work_dir), timeout=5
|
|
622
|
+
)
|
|
623
|
+
current_branch = cur_stdout.strip()
|
|
624
|
+
|
|
625
|
+
# Get all branches with commit info
|
|
626
|
+
stdout, returncode = await _run_git(
|
|
627
|
+
[
|
|
628
|
+
"git", "branch",
|
|
629
|
+
"--format=%(refname:short)|%(objectname:short)|%(committerdate:unix)|%(subject)"
|
|
630
|
+
],
|
|
631
|
+
str(work_dir), timeout=10
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
if returncode != 0:
|
|
635
|
+
return {"branches": [], "current": current_branch}
|
|
636
|
+
|
|
637
|
+
branches = []
|
|
638
|
+
for line in stdout.strip().split('\n'):
|
|
639
|
+
if not line:
|
|
640
|
+
continue
|
|
641
|
+
parts = line.split('|', 3)
|
|
642
|
+
if len(parts) >= 4:
|
|
643
|
+
branches.append({
|
|
644
|
+
"name": parts[0],
|
|
645
|
+
"hashShort": parts[1],
|
|
646
|
+
"timestamp": int(parts[2]) if parts[2].isdigit() else 0,
|
|
647
|
+
"subject": parts[3],
|
|
648
|
+
"isCurrent": parts[0] == current_branch,
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
return {"branches": branches, "current": current_branch}
|
|
652
|
+
|
|
653
|
+
except asyncio.TimeoutError:
|
|
654
|
+
raise HTTPException(status_code=504, detail="Git command timed out")
|
|
655
|
+
except Exception as e:
|
|
656
|
+
logger.error(f"Git branches error: {e}")
|
|
657
|
+
raise HTTPException(status_code=500, detail=str(e))
|