octo-agent 0.11.2
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.
- checksums.yaml +7 -0
- data/.clacky/skills/commit/SKILL.md +423 -0
- data/.clacky/skills/gem-release/SKILL.md +199 -0
- data/.clacky/skills/gem-release/scripts/release.sh +304 -0
- data/.clacky/skills/oss-upload/SKILL.md +47 -0
- data/.octorules +106 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +76 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/CONTRIBUTING.md +92 -0
- data/Dockerfile +28 -0
- data/LICENSE.txt +22 -0
- data/POSITIONING.md +46 -0
- data/README.md +134 -0
- data/README_CN.md +134 -0
- data/Rakefile +34 -0
- data/benchmark/fixtures/sample_project/Gemfile +3 -0
- data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
- data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
- data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
- data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
- data/benchmark/results/EVALUATION_REPORT.md +165 -0
- data/benchmark/results/baseline_20260511_174424.json +128 -0
- data/benchmark/results/report_20260511_175256.json +271 -0
- data/benchmark/results/report_20260511_175444.json +271 -0
- data/benchmark/results/treatment_20260511_175103.json +130 -0
- data/benchmark/runner.rb +441 -0
- data/bin/octo +7 -0
- data/docs/agent-first-ui-design.md +77 -0
- data/docs/billing-system.md +318 -0
- data/docs/channel-architecture.md +235 -0
- data/docs/engineering-article.md +343 -0
- data/docs/session-skill-invocation.md +69 -0
- data/docs/time_machine_design.md +247 -0
- data/docs/ui2-architecture.md +124 -0
- data/homebrew/README.md +96 -0
- data/homebrew/openocto.rb +24 -0
- data/lib/octo/agent/hook_manager.rb +61 -0
- data/lib/octo/agent/llm_caller.rb +800 -0
- data/lib/octo/agent/memory_updater.rb +246 -0
- data/lib/octo/agent/message_compressor.rb +225 -0
- data/lib/octo/agent/message_compressor_helper.rb +869 -0
- data/lib/octo/agent/next_message_suggester.rb +215 -0
- data/lib/octo/agent/session_serializer.rb +685 -0
- data/lib/octo/agent/skill_auto_creator.rb +114 -0
- data/lib/octo/agent/skill_evolution.rb +61 -0
- data/lib/octo/agent/skill_manager.rb +466 -0
- data/lib/octo/agent/skill_reflector.rb +89 -0
- data/lib/octo/agent/system_prompt_builder.rb +101 -0
- data/lib/octo/agent/time_machine.rb +214 -0
- data/lib/octo/agent/tool_executor.rb +454 -0
- data/lib/octo/agent/tool_registry.rb +150 -0
- data/lib/octo/agent.rb +2180 -0
- data/lib/octo/agent_config.rb +989 -0
- data/lib/octo/agent_profile.rb +112 -0
- data/lib/octo/anthropic_stream_aggregator.rb +137 -0
- data/lib/octo/background_task_registry.rb +324 -0
- data/lib/octo/banner.rb +34 -0
- data/lib/octo/bedrock_stream_aggregator.rb +137 -0
- data/lib/octo/block_font.rb +331 -0
- data/lib/octo/cli.rb +968 -0
- data/lib/octo/client.rb +623 -0
- data/lib/octo/default_agents/SOUL.md +3 -0
- data/lib/octo/default_agents/USER.md +1 -0
- data/lib/octo/default_agents/base_prompt.md +66 -0
- data/lib/octo/default_agents/coding/profile.yml +2 -0
- data/lib/octo/default_agents/coding/system_prompt.md +67 -0
- data/lib/octo/default_agents/general/profile.yml +2 -0
- data/lib/octo/default_agents/general/system_prompt.md +16 -0
- data/lib/octo/default_parsers/doc_parser.rb +69 -0
- data/lib/octo/default_parsers/docx_parser.rb +188 -0
- data/lib/octo/default_parsers/pdf_parser.rb +120 -0
- data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
- data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
- data/lib/octo/default_parsers/pptx_parser.rb +140 -0
- data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
- data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
- data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
- data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
- data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
- data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
- data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
- data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
- data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
- data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
- data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
- data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
- data/lib/octo/default_skills/onboard/SKILL.md +578 -0
- data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
- data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
- data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
- data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
- data/lib/octo/default_skills/personal-website/publish.rb +235 -0
- data/lib/octo/default_skills/product-help/SKILL.md +123 -0
- data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
- data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
- data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
- data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
- data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
- data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
- data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
- data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
- data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
- data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
- data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
- data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
- data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
- data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
- data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
- data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
- data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
- data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
- data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
- data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
- data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
- data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
- data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
- data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
- data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
- data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
- data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
- data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
- data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
- data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
- data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
- data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
- data/lib/octo/idle_compression_timer.rb +115 -0
- data/lib/octo/json_ui_controller.rb +204 -0
- data/lib/octo/message_format/anthropic.rb +409 -0
- data/lib/octo/message_format/bedrock.rb +361 -0
- data/lib/octo/message_format/open_ai.rb +222 -0
- data/lib/octo/message_history.rb +373 -0
- data/lib/octo/openai_stream_aggregator.rb +130 -0
- data/lib/octo/plain_ui_controller.rb +166 -0
- data/lib/octo/providers.rb +534 -0
- data/lib/octo/server/browser_manager.rb +397 -0
- data/lib/octo/server/channel/adapters/base.rb +82 -0
- data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
- data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
- data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
- data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
- data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
- data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
- data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
- data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
- data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
- data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
- data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
- data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
- data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
- data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
- data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
- data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
- data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
- data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
- data/lib/octo/server/channel/channel_config.rb +178 -0
- data/lib/octo/server/channel/channel_manager.rb +468 -0
- data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
- data/lib/octo/server/channel.rb +33 -0
- data/lib/octo/server/discover.rb +77 -0
- data/lib/octo/server/epipe_safe_io.rb +105 -0
- data/lib/octo/server/http_server.rb +3554 -0
- data/lib/octo/server/scheduler.rb +317 -0
- data/lib/octo/server/server_master.rb +325 -0
- data/lib/octo/server/session_registry.rb +431 -0
- data/lib/octo/server/web_ui_controller.rb +487 -0
- data/lib/octo/session_manager.rb +385 -0
- data/lib/octo/skill.rb +466 -0
- data/lib/octo/skill_loader.rb +328 -0
- data/lib/octo/tools/base.rb +118 -0
- data/lib/octo/tools/browser.rb +625 -0
- data/lib/octo/tools/edit.rb +165 -0
- data/lib/octo/tools/file_reader.rb +549 -0
- data/lib/octo/tools/glob.rb +162 -0
- data/lib/octo/tools/grep.rb +356 -0
- data/lib/octo/tools/invoke_skill.rb +96 -0
- data/lib/octo/tools/list_tasks.rb +54 -0
- data/lib/octo/tools/redo_task.rb +41 -0
- data/lib/octo/tools/request_user_feedback.rb +84 -0
- data/lib/octo/tools/security.rb +333 -0
- data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
- data/lib/octo/tools/terminal/persistent_session.rb +268 -0
- data/lib/octo/tools/terminal/safe_rm.sh +106 -0
- data/lib/octo/tools/terminal/session_manager.rb +213 -0
- data/lib/octo/tools/terminal.rb +1828 -0
- data/lib/octo/tools/todo_manager.rb +374 -0
- data/lib/octo/tools/trash_manager.rb +388 -0
- data/lib/octo/tools/undo_task.rb +35 -0
- data/lib/octo/tools/web_fetch.rb +242 -0
- data/lib/octo/tools/web_search.rb +260 -0
- data/lib/octo/tools/write.rb +77 -0
- data/lib/octo/ui2/block_font.rb +10 -0
- data/lib/octo/ui2/components/base_component.rb +163 -0
- data/lib/octo/ui2/components/command_suggestions.rb +290 -0
- data/lib/octo/ui2/components/common_component.rb +96 -0
- data/lib/octo/ui2/components/inline_input.rb +226 -0
- data/lib/octo/ui2/components/input_area.rb +1338 -0
- data/lib/octo/ui2/components/message_component.rb +99 -0
- data/lib/octo/ui2/components/modal_component.rb +419 -0
- data/lib/octo/ui2/components/todo_area.rb +149 -0
- data/lib/octo/ui2/components/tool_component.rb +107 -0
- data/lib/octo/ui2/components/welcome_banner.rb +139 -0
- data/lib/octo/ui2/layout_manager.rb +807 -0
- data/lib/octo/ui2/line_editor.rb +363 -0
- data/lib/octo/ui2/markdown_renderer.rb +100 -0
- data/lib/octo/ui2/output_buffer.rb +370 -0
- data/lib/octo/ui2/progress_handle.rb +362 -0
- data/lib/octo/ui2/progress_indicator.rb +55 -0
- data/lib/octo/ui2/screen_buffer.rb +273 -0
- data/lib/octo/ui2/terminal_detector.rb +119 -0
- data/lib/octo/ui2/theme_manager.rb +85 -0
- data/lib/octo/ui2/themes/base_theme.rb +105 -0
- data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
- data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
- data/lib/octo/ui2/thinking_verbs.rb +26 -0
- data/lib/octo/ui2/ui_controller.rb +1625 -0
- data/lib/octo/ui2/view_renderer.rb +177 -0
- data/lib/octo/ui2.rb +40 -0
- data/lib/octo/ui_interface.rb +154 -0
- data/lib/octo/utils/arguments_parser.rb +191 -0
- data/lib/octo/utils/browser_detector.rb +195 -0
- data/lib/octo/utils/encoding.rb +92 -0
- data/lib/octo/utils/environment_detector.rb +140 -0
- data/lib/octo/utils/file_ignore_helper.rb +170 -0
- data/lib/octo/utils/file_processor.rb +601 -0
- data/lib/octo/utils/gitignore_parser.rb +154 -0
- data/lib/octo/utils/limit_stack.rb +152 -0
- data/lib/octo/utils/logger.rb +124 -0
- data/lib/octo/utils/login_shell.rb +72 -0
- data/lib/octo/utils/model_pricing.rb +646 -0
- data/lib/octo/utils/parser_manager.rb +165 -0
- data/lib/octo/utils/path_helper.rb +15 -0
- data/lib/octo/utils/scripts_manager.rb +59 -0
- data/lib/octo/utils/string_matcher.rb +158 -0
- data/lib/octo/utils/trash_directory.rb +112 -0
- data/lib/octo/utils/workspace_rules.rb +46 -0
- data/lib/octo/version.rb +5 -0
- data/lib/octo/web/app.css +7141 -0
- data/lib/octo/web/app.js +543 -0
- data/lib/octo/web/apple-touch-icon.png +0 -0
- data/lib/octo/web/auth.js +150 -0
- data/lib/octo/web/channels.js +276 -0
- data/lib/octo/web/datepicker.js +205 -0
- data/lib/octo/web/favicon.png +0 -0
- data/lib/octo/web/i18n.js +1073 -0
- data/lib/octo/web/icon-512.png +0 -0
- data/lib/octo/web/icon-dark.svg +25 -0
- data/lib/octo/web/icon.svg +29 -0
- data/lib/octo/web/index.html +871 -0
- data/lib/octo/web/marked.min.js +69 -0
- data/lib/octo/web/onboard.js +491 -0
- data/lib/octo/web/profile.js +442 -0
- data/lib/octo/web/sessions.js +4421 -0
- data/lib/octo/web/settings.js +913 -0
- data/lib/octo/web/sidebar.js +32 -0
- data/lib/octo/web/skills.js +885 -0
- data/lib/octo/web/tasks.js +297 -0
- data/lib/octo/web/theme.js +105 -0
- data/lib/octo/web/trash.js +343 -0
- data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
- data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
- data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/katex.min.css +1 -0
- data/lib/octo/web/vendor/katex/katex.min.js +1 -0
- data/lib/octo/web/version.js +449 -0
- data/lib/octo/web/weixin-qr.html +209 -0
- data/lib/octo/web/ws-dispatcher.js +357 -0
- data/lib/octo/web/ws.js +128 -0
- data/lib/octo.rb +145 -0
- data/scripts/build/build.sh +329 -0
- data/scripts/build/lib/apt.sh +56 -0
- data/scripts/build/lib/brew.sh +89 -0
- data/scripts/build/lib/colors.sh +17 -0
- data/scripts/build/lib/gem.sh +95 -0
- data/scripts/build/lib/mise.sh +125 -0
- data/scripts/build/lib/network.sh +157 -0
- data/scripts/build/lib/os.sh +57 -0
- data/scripts/build/lib/shell.sh +37 -0
- data/scripts/build/src/install.sh.cc +174 -0
- data/scripts/build/src/install_browser.sh.cc +101 -0
- data/scripts/build/src/install_full.sh.cc +290 -0
- data/scripts/build/src/install_rails_deps.sh.cc +145 -0
- data/scripts/build/src/install_system_deps.sh.cc +123 -0
- data/scripts/build/src/uninstall.sh.cc +101 -0
- data/scripts/install.ps1 +532 -0
- data/scripts/install.sh +567 -0
- data/scripts/install_browser.sh +479 -0
- data/scripts/install_full.sh +838 -0
- data/scripts/install_rails_deps.sh +746 -0
- data/scripts/install_system_deps.sh +518 -0
- data/scripts/uninstall.sh +287 -0
- data/sig/octo.rbs +4 -0
- metadata +614 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Octo
|
|
6
|
+
module Utils
|
|
7
|
+
# Detects a running browser (Chrome/Edge) that has remote debugging enabled.
|
|
8
|
+
#
|
|
9
|
+
# Detection strategy:
|
|
10
|
+
#
|
|
11
|
+
# 1. Scan known UserData directories for DevToolsActivePort file.
|
|
12
|
+
# This file contains the exact port + WS path — most reliable.
|
|
13
|
+
# Returns { mode: :ws_endpoint, value: "ws://127.0.0.1:PORT/PATH" }
|
|
14
|
+
#
|
|
15
|
+
# 2. Verify the port is actually reachable via TCP probe.
|
|
16
|
+
#
|
|
17
|
+
# 3. Nothing found or port unreachable → returns nil (browser not running).
|
|
18
|
+
#
|
|
19
|
+
# Supported environments: WSL, Linux, macOS.
|
|
20
|
+
module BrowserDetector
|
|
21
|
+
|
|
22
|
+
# Detect a running debuggable browser.
|
|
23
|
+
# Scans for DevToolsActivePort file across all platforms (macOS/Linux/WSL).
|
|
24
|
+
# Returns the detected WebSocket endpoint only if the port is reachable.
|
|
25
|
+
# @return [Hash] { mode: :ws_endpoint, value: String, status: :ok|:not_found }
|
|
26
|
+
def self.detect
|
|
27
|
+
os = EnvironmentDetector.os_type
|
|
28
|
+
Octo::Logger.debug("[BrowserDetector] Starting browser detection (OS: #{os})...")
|
|
29
|
+
|
|
30
|
+
detected = detect_via_active_port_file
|
|
31
|
+
|
|
32
|
+
unless detected
|
|
33
|
+
Octo::Logger.warn("[BrowserDetector] ✗ No reachable browser found")
|
|
34
|
+
return { status: :not_found }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Octo::Logger.info("[BrowserDetector] ✓ Browser detected and reachable: #{detected[:mode]} → #{detected[:value]}")
|
|
38
|
+
detected.merge(status: :ok)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# -----------------------------------------------------------------------
|
|
42
|
+
# DevToolsActivePort file scan
|
|
43
|
+
# -----------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
# @return [Hash, nil]
|
|
46
|
+
def self.detect_via_active_port_file
|
|
47
|
+
Octo::Logger.debug("[BrowserDetector] Scanning UserData directories for DevToolsActivePort...")
|
|
48
|
+
|
|
49
|
+
dirs = user_data_dirs
|
|
50
|
+
Octo::Logger.debug("[BrowserDetector] Candidate directories: #{dirs.size} found")
|
|
51
|
+
|
|
52
|
+
dirs.each do |dir|
|
|
53
|
+
port_file = File.join(dir, "DevToolsActivePort")
|
|
54
|
+
next unless File.exist?(port_file)
|
|
55
|
+
|
|
56
|
+
Octo::Logger.debug("[BrowserDetector] Found DevToolsActivePort: #{port_file}")
|
|
57
|
+
|
|
58
|
+
ws = parse_active_port_file(port_file)
|
|
59
|
+
unless ws
|
|
60
|
+
Octo::Logger.debug("[BrowserDetector] ✗ Failed to parse #{port_file}")
|
|
61
|
+
next
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
Octo::Logger.debug("[BrowserDetector] Parsed WS endpoint: #{ws}")
|
|
65
|
+
|
|
66
|
+
# ⭐️ Verify port BEFORE returning — skip stale files
|
|
67
|
+
candidate = { mode: :ws_endpoint, value: ws }
|
|
68
|
+
if verify_port(candidate)
|
|
69
|
+
Octo::Logger.debug("[BrowserDetector] ✓ Port is reachable, using this endpoint")
|
|
70
|
+
return candidate
|
|
71
|
+
else
|
|
72
|
+
Octo::Logger.debug("[BrowserDetector] ✗ Port not reachable, trying next directory...")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
Octo::Logger.debug("[BrowserDetector] No reachable browser found")
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Verify that the detected browser port is actually reachable.
|
|
81
|
+
# Extracts port from ws:// URL and attempts TCP connection.
|
|
82
|
+
# @param detected [Hash] { mode: :ws_endpoint, value: String }
|
|
83
|
+
# @return [Boolean] true if port is open and reachable
|
|
84
|
+
def self.verify_port(detected)
|
|
85
|
+
return false unless detected
|
|
86
|
+
|
|
87
|
+
port = case detected[:mode]
|
|
88
|
+
when :ws_endpoint
|
|
89
|
+
# ws://127.0.0.1:9222/devtools/...
|
|
90
|
+
detected[:value][/ws:\/\/127\.0\.0\.1:(\d+)/, 1]&.to_i
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
return false unless port && port > 0
|
|
94
|
+
|
|
95
|
+
reachable = tcp_open?("127.0.0.1", port)
|
|
96
|
+
Octo::Logger.debug("[BrowserDetector] Port #{port} reachable: #{reachable}")
|
|
97
|
+
reachable
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# -----------------------------------------------------------------------
|
|
101
|
+
# UserData directory candidates per OS
|
|
102
|
+
# -----------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
# Returns ordered list of candidate UserData dirs to check.
|
|
105
|
+
# @return [Array<String>]
|
|
106
|
+
def self.user_data_dirs
|
|
107
|
+
os = EnvironmentDetector.os_type
|
|
108
|
+
Octo::Logger.debug("[BrowserDetector] Detected OS: #{os}")
|
|
109
|
+
|
|
110
|
+
case os
|
|
111
|
+
when :wsl then wsl_user_data_dirs
|
|
112
|
+
when :linux then linux_user_data_dirs
|
|
113
|
+
when :macos then macos_user_data_dirs
|
|
114
|
+
else
|
|
115
|
+
Octo::Logger.warn("[BrowserDetector] Unknown OS type: #{os}")
|
|
116
|
+
[]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# WSL: Chrome/Edge run on Windows side — resolve via LOCALAPPDATA.
|
|
121
|
+
private_class_method def self.wsl_user_data_dirs
|
|
122
|
+
appdata = Utils::Encoding.cmd_to_utf8(
|
|
123
|
+
`powershell.exe -NoProfile -Command '$env:LOCALAPPDATA' 2>/dev/null`
|
|
124
|
+
).strip.tr("\r\n", "")
|
|
125
|
+
return [] if appdata.empty?
|
|
126
|
+
|
|
127
|
+
win_paths = [
|
|
128
|
+
"#{appdata}\\Microsoft\\Edge\\User Data",
|
|
129
|
+
"#{appdata}\\Google\\Chrome\\User Data",
|
|
130
|
+
"#{appdata}\\Google\\Chrome Beta\\User Data",
|
|
131
|
+
"#{appdata}\\Google\\Chrome SxS\\User Data",
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
win_paths.filter_map do |win_path|
|
|
135
|
+
linux_path = Utils::Encoding.cmd_to_utf8(
|
|
136
|
+
`wslpath '#{win_path}' 2>/dev/null`, source_encoding: "UTF-8"
|
|
137
|
+
).strip
|
|
138
|
+
linux_path.empty? ? nil : linux_path
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Linux: standard XDG config paths for Chrome and Edge.
|
|
143
|
+
private_class_method def self.linux_user_data_dirs
|
|
144
|
+
config_home = ENV["XDG_CONFIG_HOME"] || File.join(Dir.home, ".config")
|
|
145
|
+
[
|
|
146
|
+
File.join(config_home, "microsoft-edge"),
|
|
147
|
+
File.join(config_home, "google-chrome"),
|
|
148
|
+
File.join(config_home, "google-chrome-beta"),
|
|
149
|
+
File.join(config_home, "google-chrome-unstable"),
|
|
150
|
+
]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# macOS: Application Support paths for Chrome and Edge.
|
|
154
|
+
private_class_method def self.macos_user_data_dirs
|
|
155
|
+
base = File.join(Dir.home, "Library", "Application Support")
|
|
156
|
+
[
|
|
157
|
+
File.join(base, "Microsoft Edge"),
|
|
158
|
+
File.join(base, "Google", "Chrome"),
|
|
159
|
+
File.join(base, "Google", "Chrome Beta"),
|
|
160
|
+
File.join(base, "Google", "Chrome Canary"),
|
|
161
|
+
]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# -----------------------------------------------------------------------
|
|
165
|
+
# Helpers
|
|
166
|
+
# -----------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
# Parse DevToolsActivePort file.
|
|
169
|
+
# Format: first line = port number, second line = WS path
|
|
170
|
+
# @return [String, nil] ws://127.0.0.1:PORT/PATH or nil on parse error
|
|
171
|
+
private_class_method def self.parse_active_port_file(path)
|
|
172
|
+
lines = File.read(path, encoding: "utf-8").split("\n").map(&:strip).reject(&:empty?)
|
|
173
|
+
return nil unless lines.size >= 2
|
|
174
|
+
|
|
175
|
+
port = lines[0].to_i
|
|
176
|
+
ws_path = lines[1]
|
|
177
|
+
return nil if port <= 0 || port > 65_535 || ws_path.empty?
|
|
178
|
+
|
|
179
|
+
"ws://127.0.0.1:#{port}#{ws_path}"
|
|
180
|
+
rescue StandardError
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Probe TCP port with a short timeout to verify port is actually reachable.
|
|
185
|
+
# @param host [String] hostname
|
|
186
|
+
# @param port [Integer] port number
|
|
187
|
+
# @return [Boolean] true if port is open and reachable
|
|
188
|
+
private_class_method def self.tcp_open?(host, port)
|
|
189
|
+
Socket.tcp(host, port, connect_timeout: 0.5) { true }
|
|
190
|
+
rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError, Errno::EHOSTUNREACH
|
|
191
|
+
false
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octo
|
|
4
|
+
module Utils
|
|
5
|
+
# Centralised UTF-8 encoding helpers used throughout the codebase.
|
|
6
|
+
#
|
|
7
|
+
# Three distinct use-cases exist:
|
|
8
|
+
#
|
|
9
|
+
# 1. to_utf8 – binary/unknown bytes → valid UTF-8 String.
|
|
10
|
+
# Used when reading shell output, HTTP response bodies,
|
|
11
|
+
# or any raw byte stream that is *expected* to be UTF-8
|
|
12
|
+
# but arrives with ASCII-8BIT (binary) encoding.
|
|
13
|
+
# Strategy: force_encoding("UTF-8") then scrub invalid
|
|
14
|
+
# sequences with U+FFFD so multibyte characters (CJK,
|
|
15
|
+
# emoji, …) are preserved as-is.
|
|
16
|
+
#
|
|
17
|
+
# 2. sanitize_utf8 – UTF-8 String → clean UTF-8 String.
|
|
18
|
+
# Used for UI rendering (terminal output, screen
|
|
19
|
+
# buffers) where the string is already nominally UTF-8
|
|
20
|
+
# but may still contain isolated invalid bytes.
|
|
21
|
+
# Strategy: encode UTF-8→UTF-8 replacing invalid /
|
|
22
|
+
# undefined codepoints with an empty string so the
|
|
23
|
+
# rendered output never contains replacement characters.
|
|
24
|
+
#
|
|
25
|
+
# 3. safe_check – any String → ASCII-safe UTF-8 String for regex.
|
|
26
|
+
# Used only for security pattern matching (terminal/Security).
|
|
27
|
+
# Multibyte bytes are replaced with '?' so that Ruby's
|
|
28
|
+
# regex engine operates on a plain ASCII-compatible
|
|
29
|
+
# string without raising Encoding errors.
|
|
30
|
+
#
|
|
31
|
+
module Encoding
|
|
32
|
+
# Convert a binary (or unknown-encoding) byte string to a valid UTF-8
|
|
33
|
+
# String. Multibyte sequences that are already valid UTF-8 (e.g. CJK
|
|
34
|
+
# characters) are preserved unchanged; only genuinely invalid byte
|
|
35
|
+
# sequences are replaced with U+FFFD (the Unicode replacement character).
|
|
36
|
+
#
|
|
37
|
+
# @param data [String, nil] raw bytes, typically from a pipe or HTTP body
|
|
38
|
+
# @return [String] valid UTF-8 string
|
|
39
|
+
def self.to_utf8(data)
|
|
40
|
+
return "" if data.nil? || data.empty?
|
|
41
|
+
|
|
42
|
+
data.dup.force_encoding("UTF-8").scrub("\u{FFFD}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Clean an already-UTF-8 string by removing (not replacing) any invalid
|
|
46
|
+
# or undefined byte sequences. Suitable for terminal / UI rendering where
|
|
47
|
+
# replacement characters would appear as visual noise.
|
|
48
|
+
#
|
|
49
|
+
# @param str [String, nil] nominally UTF-8 string
|
|
50
|
+
# @return [String] clean UTF-8 string (invalid bytes silently dropped)
|
|
51
|
+
def self.sanitize_utf8(str)
|
|
52
|
+
return "" if str.nil? || str.empty?
|
|
53
|
+
|
|
54
|
+
str.encode("UTF-8", "UTF-8", invalid: :replace, undef: :replace, replace: "")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Convert raw shell command output to valid UTF-8.
|
|
58
|
+
# Handles two common cases:
|
|
59
|
+
# - Windows commands (e.g. powershell.exe) that output GBK/CP936 bytes
|
|
60
|
+
# - Unix commands that output UTF-8 or ASCII bytes with ASCII-8BIT encoding
|
|
61
|
+
#
|
|
62
|
+
# Strategy: try GBK decode first (superset of ASCII, covers Chinese Windows);
|
|
63
|
+
# if that fails fall back to UTF-8 scrub.
|
|
64
|
+
#
|
|
65
|
+
# @param data [String, nil] raw bytes from backtick / IO.popen
|
|
66
|
+
# @param source_encoding [String] hint for source encoding (default: "GBK")
|
|
67
|
+
# @return [String] valid UTF-8 string
|
|
68
|
+
def self.cmd_to_utf8(data, source_encoding: "GBK")
|
|
69
|
+
return "" if data.nil? || data.empty?
|
|
70
|
+
|
|
71
|
+
data.dup
|
|
72
|
+
.force_encoding(source_encoding)
|
|
73
|
+
.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
|
|
74
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
75
|
+
to_utf8(data)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Return an ASCII-safe UTF-8 copy of *str* suitable for security regex
|
|
79
|
+
# pattern matching. Any byte that is not valid in the source encoding, or
|
|
80
|
+
# that cannot be represented in UTF-8, is replaced with '?'. The
|
|
81
|
+
# original string is never mutated.
|
|
82
|
+
#
|
|
83
|
+
# @param str [String, nil]
|
|
84
|
+
# @return [String] UTF-8 string safe for regex matching
|
|
85
|
+
def self.safe_check(str)
|
|
86
|
+
return "" if str.nil? || str.empty?
|
|
87
|
+
|
|
88
|
+
str.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octo
|
|
4
|
+
module Utils
|
|
5
|
+
# Detects the current operating system environment and desktop path.
|
|
6
|
+
module EnvironmentDetector
|
|
7
|
+
# Detect OS type.
|
|
8
|
+
# @return [Symbol] :wsl, :linux, :macos, or :unknown
|
|
9
|
+
def self.os_type
|
|
10
|
+
return @os_type if defined?(@os_type)
|
|
11
|
+
|
|
12
|
+
@os_type = if wsl?
|
|
13
|
+
:wsl
|
|
14
|
+
elsif RUBY_PLATFORM.include?("darwin")
|
|
15
|
+
:macos
|
|
16
|
+
elsif RUBY_PLATFORM.include?("linux")
|
|
17
|
+
:linux
|
|
18
|
+
else
|
|
19
|
+
:unknown
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Open a file with the OS-default application.
|
|
24
|
+
# On WSL, uses "cmd.exe /c start" instead of explorer.exe so the opened
|
|
25
|
+
# window receives foreground focus even when called from a background
|
|
26
|
+
# thread (e.g. WEBrick request handler).
|
|
27
|
+
# @param path [String] Linux-side file path
|
|
28
|
+
# @return [Boolean, nil] true/false from system(), or nil on unsupported OS
|
|
29
|
+
def self.open_file(path)
|
|
30
|
+
case os_type
|
|
31
|
+
when :macos then system("open", path)
|
|
32
|
+
when :linux then system("xdg-open", path)
|
|
33
|
+
when :wsl
|
|
34
|
+
win_path = linux_to_win_path(path)
|
|
35
|
+
system("cmd.exe", "/c", "start", "", win_path)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Convert a Windows-style path to a WSL/Linux-side path.
|
|
40
|
+
# e.g. "C:/Users/foo/file.txt" → "/mnt/c/Users/foo/file.txt"
|
|
41
|
+
# Returns the original path unchanged on non-WSL or if already a Linux path.
|
|
42
|
+
# @param path [String]
|
|
43
|
+
# @return [String]
|
|
44
|
+
def self.win_to_linux_path(path)
|
|
45
|
+
return path unless os_type == :wsl && path.match?(/\A[A-Za-z]:[\/\\]/)
|
|
46
|
+
|
|
47
|
+
drive = path[0].downcase
|
|
48
|
+
rest = path[2..].gsub("\\", "/")
|
|
49
|
+
"/mnt/#{drive}#{rest}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Convert a Linux-side path to a Windows-style path via wslpath.
|
|
53
|
+
# e.g. "/mnt/c/Users/foo/file.txt" → "C:\Users\foo\file.txt"
|
|
54
|
+
# Returns the original path unchanged on non-WSL.
|
|
55
|
+
# @param path [String]
|
|
56
|
+
# @return [String]
|
|
57
|
+
def self.linux_to_win_path(path)
|
|
58
|
+
return path unless os_type == :wsl
|
|
59
|
+
|
|
60
|
+
Octo::Utils::Encoding.cmd_to_utf8(
|
|
61
|
+
`wslpath -w '#{path.gsub("'", "'\''")}'`,
|
|
62
|
+
source_encoding: "UTF-8"
|
|
63
|
+
).strip
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Human-readable OS label for injection into session context.
|
|
67
|
+
def self.os_label
|
|
68
|
+
case os_type
|
|
69
|
+
when :wsl then "WSL/Windows"
|
|
70
|
+
when :macos then "macOS"
|
|
71
|
+
when :linux then "Linux"
|
|
72
|
+
else "Unknown"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Detect the desktop directory path for the current environment.
|
|
77
|
+
# @return [String, nil] absolute path to desktop, or nil if not found
|
|
78
|
+
def self.desktop_path
|
|
79
|
+
return @desktop_path if defined?(@desktop_path)
|
|
80
|
+
|
|
81
|
+
@desktop_path = case os_type
|
|
82
|
+
when :wsl
|
|
83
|
+
wsl_desktop_path
|
|
84
|
+
when :macos
|
|
85
|
+
macos_desktop_path
|
|
86
|
+
when :linux
|
|
87
|
+
linux_desktop_path
|
|
88
|
+
else
|
|
89
|
+
fallback_desktop_path
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.wsl?
|
|
94
|
+
File.exist?("/proc/version") &&
|
|
95
|
+
File.read("/proc/version").downcase.include?("microsoft")
|
|
96
|
+
rescue
|
|
97
|
+
false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private_class_method def self.wsl_desktop_path
|
|
101
|
+
if Utils::Encoding.cmd_to_utf8(`which powershell.exe 2>/dev/null`).strip.empty?
|
|
102
|
+
return fallback_desktop_path
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# powershell.exe on Chinese Windows outputs GBK bytes; decode explicitly
|
|
106
|
+
win_path = Utils::Encoding.cmd_to_utf8(
|
|
107
|
+
`powershell.exe -NoProfile -Command '[Environment]::GetFolderPath("Desktop")' 2>/dev/null`
|
|
108
|
+
).strip.tr("\r\n", "")
|
|
109
|
+
return fallback_desktop_path if win_path.empty?
|
|
110
|
+
|
|
111
|
+
# wslpath output is UTF-8 (Linux side)
|
|
112
|
+
linux_path = Utils::Encoding.cmd_to_utf8(`wslpath '#{win_path}' 2>/dev/null`, source_encoding: "UTF-8").strip
|
|
113
|
+
return linux_path if !linux_path.empty? && Dir.exist?(linux_path)
|
|
114
|
+
|
|
115
|
+
fallback_desktop_path
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private_class_method def self.linux_desktop_path
|
|
119
|
+
path = Utils::Encoding.cmd_to_utf8(`xdg-user-dir DESKTOP 2>/dev/null`, source_encoding: "UTF-8").strip
|
|
120
|
+
return path if !path.empty? && path != Dir.home && Dir.exist?(path)
|
|
121
|
+
|
|
122
|
+
fallback_desktop_path
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private_class_method def self.macos_desktop_path
|
|
126
|
+
path = Utils::Encoding.cmd_to_utf8(`osascript -e 'POSIX path of (path to desktop)' 2>/dev/null`, source_encoding: "UTF-8").strip.chomp("/")
|
|
127
|
+
return path if !path.empty? && Dir.exist?(path)
|
|
128
|
+
|
|
129
|
+
fallback_desktop_path
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private_class_method def self.fallback_desktop_path
|
|
133
|
+
[
|
|
134
|
+
File.join(Dir.home, "Desktop"),
|
|
135
|
+
File.join(Dir.home, "桌面"),
|
|
136
|
+
].find { |p| Dir.exist?(p) }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octo
|
|
4
|
+
module Utils
|
|
5
|
+
# Helper module for file ignoring functionality shared between tools
|
|
6
|
+
module FileIgnoreHelper
|
|
7
|
+
# Default patterns to ignore when .gitignore is not available
|
|
8
|
+
DEFAULT_IGNORED_PATTERNS = [
|
|
9
|
+
'node_modules',
|
|
10
|
+
'vendor/bundle',
|
|
11
|
+
'.git',
|
|
12
|
+
'.svn',
|
|
13
|
+
'tmp',
|
|
14
|
+
'log',
|
|
15
|
+
'coverage',
|
|
16
|
+
'dist',
|
|
17
|
+
'build',
|
|
18
|
+
'.bundle',
|
|
19
|
+
'.sass-cache',
|
|
20
|
+
'.DS_Store',
|
|
21
|
+
'*.log'
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
# Config file patterns that should always be searchable/visible
|
|
25
|
+
CONFIG_FILE_PATTERNS = [
|
|
26
|
+
/\.env/,
|
|
27
|
+
/\.ya?ml$/,
|
|
28
|
+
/\.json$/,
|
|
29
|
+
/\.toml$/,
|
|
30
|
+
/\.ini$/,
|
|
31
|
+
/\.conf$/,
|
|
32
|
+
/\.config$/,
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
# Find .gitignore file in the search path or parent directories
|
|
36
|
+
# Only searches within the search path and up to the current working directory
|
|
37
|
+
def self.find_gitignore(path)
|
|
38
|
+
search_path = File.directory?(path) ? path : File.dirname(path)
|
|
39
|
+
|
|
40
|
+
# Look for .gitignore in current and parent directories
|
|
41
|
+
current = File.expand_path(search_path)
|
|
42
|
+
cwd = File.expand_path(Dir.pwd) # intentional: gitignore boundary uses process cwd as fallback
|
|
43
|
+
root = File.expand_path('/')
|
|
44
|
+
|
|
45
|
+
# Limit search: only go up to current working directory
|
|
46
|
+
# This prevents finding .gitignore files from unrelated parent directories
|
|
47
|
+
# when searching in temporary directories (like /tmp in tests)
|
|
48
|
+
search_limit = if current.start_with?(cwd)
|
|
49
|
+
cwd
|
|
50
|
+
else
|
|
51
|
+
current
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
loop do
|
|
55
|
+
gitignore = File.join(current, '.gitignore')
|
|
56
|
+
return gitignore if File.exist?(gitignore)
|
|
57
|
+
|
|
58
|
+
# Stop if we've reached the search limit or root
|
|
59
|
+
break if current == search_limit || current == root
|
|
60
|
+
current = File.dirname(current)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Directories that are always ignored regardless of .gitignore rules
|
|
67
|
+
ALWAYS_IGNORED_DIRS = ['.git', '.svn', '.hg'].freeze
|
|
68
|
+
|
|
69
|
+
# Check if file should be ignored based on .gitignore or default patterns
|
|
70
|
+
def self.should_ignore_file?(file, base_path, gitignore)
|
|
71
|
+
# Always calculate path relative to base_path for consistency
|
|
72
|
+
# Expand both paths to handle symlinks and relative paths correctly
|
|
73
|
+
expanded_file = File.expand_path(file)
|
|
74
|
+
expanded_base = File.expand_path(base_path)
|
|
75
|
+
|
|
76
|
+
# For files, use the directory as base
|
|
77
|
+
expanded_base = File.dirname(expanded_base) if File.file?(expanded_base)
|
|
78
|
+
|
|
79
|
+
# Calculate relative path
|
|
80
|
+
if expanded_file.start_with?(expanded_base)
|
|
81
|
+
relative_path = expanded_file[(expanded_base.length + 1)..-1] || File.basename(expanded_file)
|
|
82
|
+
else
|
|
83
|
+
# File is outside base path - use just the filename
|
|
84
|
+
relative_path = File.basename(expanded_file)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Clean up relative path
|
|
88
|
+
relative_path = relative_path.sub(/^\.\//, '') if relative_path
|
|
89
|
+
|
|
90
|
+
# Always ignore version control directories regardless of .gitignore rules
|
|
91
|
+
return true if ALWAYS_IGNORED_DIRS.any? do |dir|
|
|
92
|
+
relative_path.start_with?("#{dir}/") || relative_path == dir
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
if gitignore
|
|
96
|
+
# Use .gitignore rules
|
|
97
|
+
gitignore.ignored?(relative_path)
|
|
98
|
+
else
|
|
99
|
+
# Use default ignore patterns - only match against relative path components
|
|
100
|
+
DEFAULT_IGNORED_PATTERNS.any? do |pattern|
|
|
101
|
+
if pattern.include?('*')
|
|
102
|
+
File.fnmatch(pattern, relative_path, File::FNM_PATHNAME | File::FNM_DOTMATCH)
|
|
103
|
+
else
|
|
104
|
+
# Match pattern as a path component (not substring of absolute path)
|
|
105
|
+
relative_path.start_with?("#{pattern}/") ||
|
|
106
|
+
relative_path.include?("/#{pattern}/") ||
|
|
107
|
+
relative_path == pattern ||
|
|
108
|
+
File.basename(relative_path) == pattern
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Check if file is a config file (should not be ignored even if in .gitignore)
|
|
115
|
+
def self.is_config_file?(file)
|
|
116
|
+
CONFIG_FILE_PATTERNS.any? { |pattern| file.match?(pattern) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Walk a directory tree, pruning ignored directories early.
|
|
120
|
+
# Yields each non-ignored file path. Supports nested .gitignore files.
|
|
121
|
+
# @param skipped [Hash, nil] If provided, increments :ignored for each gitignore-skipped entry.
|
|
122
|
+
def self.walk_files(base_path, gitignore: nil, skipped: nil, &block)
|
|
123
|
+
return enum_for(:walk_files, base_path, gitignore: gitignore, skipped: skipped) unless block_given?
|
|
124
|
+
|
|
125
|
+
root_gitignore = gitignore || begin
|
|
126
|
+
gi_path = find_gitignore(base_path)
|
|
127
|
+
gi_path ? Octo::GitignoreParser.new(gi_path) : nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
_walk_recursive(base_path, base_path, root_gitignore, skipped, &block)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self._walk_recursive(dir, base_path, gitignore, skipped, &block)
|
|
134
|
+
child_gitignore_path = File.join(dir, ".gitignore")
|
|
135
|
+
if dir != base_path && File.exist?(child_gitignore_path)
|
|
136
|
+
gitignore ||= Octo::GitignoreParser.new(nil)
|
|
137
|
+
relative_dir = dir[(base_path.length + 1)..]
|
|
138
|
+
gitignore.merge!(child_gitignore_path, prefix: relative_dir)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
begin
|
|
142
|
+
entries = Dir.children(dir)
|
|
143
|
+
rescue Errno::EACCES, Errno::ENOENT
|
|
144
|
+
return
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
entries.sort.each do |name|
|
|
148
|
+
full = File.join(dir, name)
|
|
149
|
+
relative = full[(base_path.length + 1)..]
|
|
150
|
+
|
|
151
|
+
if File.directory?(full)
|
|
152
|
+
next if ALWAYS_IGNORED_DIRS.include?(name)
|
|
153
|
+
if gitignore&.ignored?("#{relative}/") || should_ignore_file?(full, base_path, gitignore)
|
|
154
|
+
next
|
|
155
|
+
end
|
|
156
|
+
_walk_recursive(full, base_path, gitignore, skipped, &block)
|
|
157
|
+
else
|
|
158
|
+
if !is_config_file?(full) && should_ignore_file?(full, base_path, gitignore)
|
|
159
|
+
skipped[:ignored] += 1 if skipped
|
|
160
|
+
next
|
|
161
|
+
end
|
|
162
|
+
yield full
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
private_class_method :_walk_recursive
|
|
167
|
+
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|