automagik-forge 0.1.13 → 0.1.14
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.
- package/README.md +143 -447
- package/dist/linux-x64/automagik-forge-mcp.zip +0 -0
- package/{npx-cli/automagik-forge-0.0.55.tgz → dist/linux-x64/automagik-forge.zip} +0 -0
- package/package.json +13 -23
- package/.cargo/config.toml +0 -13
- package/.claude/commands/commit.md +0 -376
- package/.claude/commands/prompt.md +0 -871
- package/.env.example +0 -20
- package/.github/actions/setup-node/action.yml +0 -29
- package/.github/images/automagik-logo.png +0 -0
- package/.github/workflows/pre-release.yml +0 -470
- package/.github/workflows/publish.yml +0 -145
- package/.github/workflows/test.yml +0 -63
- package/.mcp.json +0 -57
- package/AGENT.md +0 -40
- package/CLAUDE.md +0 -40
- package/CODE-OF-CONDUCT.md +0 -89
- package/Cargo.toml +0 -19
- package/Dockerfile +0 -43
- package/LICENSE +0 -201
- package/Makefile +0 -97
- package/backend/.sqlx/query-01b7e2bac1261d8be3d03c03df3e5220590da6c31c77f161074fc62752d63881.json +0 -12
- package/backend/.sqlx/query-03f2b02ba6dc5ea2b3cf6b1004caea0ad6bcc10ebd63f441d321a389f026e263.json +0 -12
- package/backend/.sqlx/query-0923b77d137a29fc54d399a873ff15fc4af894490bc65a4d344a7575cb0d8643.json +0 -12
- package/backend/.sqlx/query-0f808bcdb63c5f180836e448dd64c435c51758b2fc54a52ce9e67495b1ab200e.json +0 -68
- package/backend/.sqlx/query-1268afe9ca849daa6722e3df7ca8e9e61f0d37052e782bb5452ab8e1018d9b63.json +0 -12
- package/backend/.sqlx/query-1b082630a9622f8667ee7a9aba2c2d3176019a68c6bb83d33008594821415a57.json +0 -12
- package/backend/.sqlx/query-1c7b06ba1e112abf6b945a2ff08a0b40ec23f3738c2e7399f067b558cf8d490e.json +0 -12
- package/backend/.sqlx/query-1f619f01f46859a64ded531dd0ef61abacfe62e758abe7030a6aa745140b95ca.json +0 -104
- package/backend/.sqlx/query-1fca1ce14b4b20205364cd1f1f45ebe1d2e30cd745e59e189d56487b5639dfbb.json +0 -12
- package/backend/.sqlx/query-212828320e8d871ab9d83705a040b23bcf0393dc7252177fc539a74657f578ef.json +0 -32
- package/backend/.sqlx/query-290ce5c152be8d36e58ff42570f9157beb07ab9e77a03ec6fc30b4f56f9b8f6b.json +0 -56
- package/backend/.sqlx/query-2b471d2c2e8ffbe0cd42d2a91b814c0d79f9d09200f147e3cea33ba4ce673c8a.json +0 -68
- package/backend/.sqlx/query-354a48c705bb9bb2048c1b7f10fcb714e23f9db82b7a4ea6932486197b2ede6a.json +0 -92
- package/backend/.sqlx/query-36c9e3dd10648e94b949db5c91a774ecb1e10a899ef95da74066eccedca4d8b2.json +0 -12
- package/backend/.sqlx/query-36e4ba7bbd81b402d5a20b6005755eafbb174c8dda442081823406ac32809a94.json +0 -56
- package/backend/.sqlx/query-3a5b3c98a55ca183ab20c74708e3d7e579dda37972c059e7515c4ceee4bd8dd3.json +0 -62
- package/backend/.sqlx/query-3d0a1cabf2a52e9d90cdfd29c509ca89aeb448d0c1d2446c65cd43db40735e86.json +0 -62
- package/backend/.sqlx/query-3d6bd16fbce59efe30b7f67ea342e0e4ea6d1432389c02468ad79f1f742d4031.json +0 -56
- package/backend/.sqlx/query-4049ca413b285a05aca6b25385e9c8185575f01e9069e4e8581aa45d713f612f.json +0 -32
- package/backend/.sqlx/query-412bacd3477d86369082e90f52240407abce436cb81292d42b2dbe1e5c18eea1.json +0 -104
- package/backend/.sqlx/query-417a8b1ff4e51de82aea0159a3b97932224dc325b23476cb84153d690227fd8b.json +0 -62
- package/backend/.sqlx/query-461cc1b0bb6fd909afc9dd2246e8526b3771cfbb0b22ae4b5d17b51af587b9e2.json +0 -56
- package/backend/.sqlx/query-58408c7a8cdeeda0bef359f1f9bd91299a339dc2b191462fc58c9736a56d5227.json +0 -92
- package/backend/.sqlx/query-5a886026d75d515c01f347cc203c8d99dd04c61dc468e2e4c5aa548436d13834.json +0 -62
- package/backend/.sqlx/query-5b902137b11022d2e1a5c4f6a9c83fec1a856c6a710aff831abd2382ede76b43.json +0 -12
- package/backend/.sqlx/query-5ed1238e52e59bb5f76c0f153fd99a14093f7ce2585bf9843585608f17ec575b.json +0 -104
- package/backend/.sqlx/query-6e8b860b14decfc2227dc57213f38442943d3fbef5c8418fd6b634c6e0f5e2ea.json +0 -104
- package/backend/.sqlx/query-6ec414276994c4ccb2433eaa5b1b342168557d17ddf5a52dac84cb1b59b9de8f.json +0 -68
- package/backend/.sqlx/query-6ecfa16d0cf825aacf233544b5baf151e9adfdca26c226ad71020d291fd802d5.json +0 -62
- package/backend/.sqlx/query-72509d252c39fce77520aa816cb2acbc1fb35dc2605e7be893610599b2427f2e.json +0 -62
- package/backend/.sqlx/query-75239b2da188f749707d77f3c1544332ca70db3d6d6743b2601dc0d167536437.json +0 -62
- package/backend/.sqlx/query-83d10e29f8478aff33434f9ac67068e013b888b953a2657e2bb72a6f619d04f2.json +0 -50
- package/backend/.sqlx/query-8610803360ea18b9b9d078a6981ea56abfbfe84e6354fc1d5ae4c622e01410ed.json +0 -68
- package/backend/.sqlx/query-86d03eb70eef39c59296416867f2ee66c9f7cd8b7f961fbda2f89fc0a1c442c2.json +0 -12
- package/backend/.sqlx/query-87d0feb5a6b442bad9c60068ea7569599cc6fc91a0e2692ecb42e93b03201b9d.json +0 -68
- package/backend/.sqlx/query-8a67b3b3337248f06a57bdf8a908f7ef23177431eaed82dc08c94c3e5944340e.json +0 -12
- package/backend/.sqlx/query-8f01ebd64bdcde6a090479f14810d73ba23020e76fd70854ac57f2da251702c3.json +0 -12
- package/backend/.sqlx/query-90fd607fcb2dca72239ff25e618e21e174b195991eaa33722cbf5f76da84cfab.json +0 -62
- package/backend/.sqlx/query-92e8bdbcd80c5ff3db7a35cf79492048803ef305cbdef0d0a1fe5dc881ca8c71.json +0 -104
- package/backend/.sqlx/query-93a1605f90e9672dad29b472b6ad85fa9a55ea3ffa5abcb8724b09d61be254ca.json +0 -20
- package/backend/.sqlx/query-9472c8fb477958167f5fae40b85ac44252468c5226b2cdd7770f027332eed6d7.json +0 -104
- package/backend/.sqlx/query-96036c4f9e0f48bdc5a4a4588f0c5f288ac7aaa5425cac40fc33f337e1a351f2.json +0 -56
- package/backend/.sqlx/query-9edb2c01e91fd0f0fe7b56e988c7ae0393150f50be3f419a981e035c0121dfc7.json +0 -104
- package/backend/.sqlx/query-a157cf00616f703bfba21927f1eb1c9eec2a81c02da15f66efdba0b6c375de1b.json +0 -26
- package/backend/.sqlx/query-a31fff84f3b8e532fd1160447d89d700f06ae08821fee00c9a5b60492b05259c.json +0 -62
- package/backend/.sqlx/query-a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314.json +0 -12
- package/backend/.sqlx/query-a6d2961718dbc3b1a925e549f49a159c561bef58c105529275f274b27e2eba5b.json +0 -104
- package/backend/.sqlx/query-a9e93d5b09b29faf66e387e4d7596a792d81e75c4d3726e83c2963e8d7c9b56f.json +0 -104
- package/backend/.sqlx/query-ac5247c8d7fb86e4650c4b0eb9420031614c831b7b085083bac20c1af314c538.json +0 -12
- package/backend/.sqlx/query-afef9467be74c411c4cb119a8b2b1aea53049877dfc30cc60b486134ba4b4c9f.json +0 -68
- package/backend/.sqlx/query-b2b2c6b4d0b1a347b5c4cb63c3a46a265d4db53be9554989a814b069d0af82f2.json +0 -62
- package/backend/.sqlx/query-c50d2ff0b12e5bcc81e371089ee2d007e233e7db93aefba4fef08e7aa68f5ab7.json +0 -20
- package/backend/.sqlx/query-c614e6056b244ca07f1b9d44e7edc9d5819225c6f8d9e077070c6e518a17f50b.json +0 -12
- package/backend/.sqlx/query-c67259be8bf4ee0cfd32167b2aa3b7fe9192809181a8171bf1c2d6df731967ae.json +0 -12
- package/backend/.sqlx/query-d2d0a1b985ebbca6a2b3e882a221a219f3199890fa640afc946ef1a792d6d8de.json +0 -12
- package/backend/.sqlx/query-d30aa5786757f32bf2b9c5fe51a45e506c71c28c5994e430d9b0546adb15ffa2.json +0 -20
- package/backend/.sqlx/query-d3b9ea1de1576af71b312924ce7f4ea8ae5dbe2ac138ea3b4470f2d5cd734846.json +0 -12
- package/backend/.sqlx/query-ed8456646fa69ddd412441955f06ff22bfb790f29466450735e0b8bb1bc4ec94.json +0 -12
- package/backend/Cargo.toml +0 -71
- package/backend/build.rs +0 -32
- package/backend/migrations/20250617183714_init.sql +0 -44
- package/backend/migrations/20250620212427_execution_processes.sql +0 -25
- package/backend/migrations/20250620214100_remove_stdout_stderr_from_task_attempts.sql +0 -28
- package/backend/migrations/20250621120000_relate_activities_to_execution_processes.sql +0 -23
- package/backend/migrations/20250623120000_executor_sessions.sql +0 -17
- package/backend/migrations/20250623130000_add_executor_type_to_execution_processes.sql +0 -4
- package/backend/migrations/20250625000000_add_dev_script_to_projects.sql +0 -4
- package/backend/migrations/20250701000000_add_branch_to_task_attempts.sql +0 -2
- package/backend/migrations/20250701000001_add_pr_tracking_to_task_attempts.sql +0 -5
- package/backend/migrations/20250701120000_add_assistant_message_to_executor_sessions.sql +0 -2
- package/backend/migrations/20250708000000_add_base_branch_to_task_attempts.sql +0 -2
- package/backend/migrations/20250709000000_add_worktree_deleted_flag.sql +0 -2
- package/backend/migrations/20250710000000_add_setup_completion.sql +0 -3
- package/backend/migrations/20250715154859_add_task_templates.sql +0 -25
- package/backend/migrations/20250716143725_add_default_templates.sql +0 -174
- package/backend/migrations/20250716161432_update_executor_names_to_kebab_case.sql +0 -20
- package/backend/migrations/20250716170000_add_parent_task_to_tasks.sql +0 -7
- package/backend/migrations/20250717000000_drop_task_attempt_activities.sql +0 -9
- package/backend/migrations/20250719000000_add_cleanup_script_to_projects.sql +0 -2
- package/backend/migrations/20250720000000_add_cleanupscript_to_process_type_constraint.sql +0 -25
- package/backend/migrations/20250723000000_add_wish_to_tasks.sql +0 -7
- package/backend/migrations/20250724000000_remove_unique_wish_constraint.sql +0 -5
- package/backend/scripts/toast-notification.ps1 +0 -23
- package/backend/sounds/abstract-sound1.wav +0 -0
- package/backend/sounds/abstract-sound2.wav +0 -0
- package/backend/sounds/abstract-sound3.wav +0 -0
- package/backend/sounds/abstract-sound4.wav +0 -0
- package/backend/sounds/cow-mooing.wav +0 -0
- package/backend/sounds/phone-vibration.wav +0 -0
- package/backend/sounds/rooster.wav +0 -0
- package/backend/src/app_state.rs +0 -218
- package/backend/src/bin/generate_types.rs +0 -189
- package/backend/src/bin/mcp_task_server.rs +0 -191
- package/backend/src/execution_monitor.rs +0 -1193
- package/backend/src/executor.rs +0 -1053
- package/backend/src/executors/amp.rs +0 -697
- package/backend/src/executors/ccr.rs +0 -91
- package/backend/src/executors/charm_opencode.rs +0 -113
- package/backend/src/executors/claude.rs +0 -887
- package/backend/src/executors/cleanup_script.rs +0 -124
- package/backend/src/executors/dev_server.rs +0 -53
- package/backend/src/executors/echo.rs +0 -79
- package/backend/src/executors/gemini/config.rs +0 -67
- package/backend/src/executors/gemini/streaming.rs +0 -363
- package/backend/src/executors/gemini.rs +0 -765
- package/backend/src/executors/mod.rs +0 -23
- package/backend/src/executors/opencode_ai.rs +0 -113
- package/backend/src/executors/setup_script.rs +0 -130
- package/backend/src/executors/sst_opencode/filter.rs +0 -184
- package/backend/src/executors/sst_opencode/tools.rs +0 -139
- package/backend/src/executors/sst_opencode.rs +0 -756
- package/backend/src/lib.rs +0 -45
- package/backend/src/main.rs +0 -324
- package/backend/src/mcp/mod.rs +0 -1
- package/backend/src/mcp/task_server.rs +0 -850
- package/backend/src/middleware/mod.rs +0 -3
- package/backend/src/middleware/model_loaders.rs +0 -242
- package/backend/src/models/api_response.rs +0 -36
- package/backend/src/models/config.rs +0 -375
- package/backend/src/models/execution_process.rs +0 -430
- package/backend/src/models/executor_session.rs +0 -225
- package/backend/src/models/mod.rs +0 -12
- package/backend/src/models/project.rs +0 -356
- package/backend/src/models/task.rs +0 -345
- package/backend/src/models/task_attempt.rs +0 -1214
- package/backend/src/models/task_template.rs +0 -146
- package/backend/src/openapi.rs +0 -93
- package/backend/src/routes/auth.rs +0 -297
- package/backend/src/routes/config.rs +0 -385
- package/backend/src/routes/filesystem.rs +0 -228
- package/backend/src/routes/health.rs +0 -16
- package/backend/src/routes/mod.rs +0 -9
- package/backend/src/routes/projects.rs +0 -562
- package/backend/src/routes/stream.rs +0 -244
- package/backend/src/routes/task_attempts.rs +0 -1172
- package/backend/src/routes/task_templates.rs +0 -229
- package/backend/src/routes/tasks.rs +0 -353
- package/backend/src/services/analytics.rs +0 -216
- package/backend/src/services/git_service.rs +0 -1321
- package/backend/src/services/github_service.rs +0 -307
- package/backend/src/services/mod.rs +0 -13
- package/backend/src/services/notification_service.rs +0 -263
- package/backend/src/services/pr_monitor.rs +0 -214
- package/backend/src/services/process_service.rs +0 -940
- package/backend/src/utils/path.rs +0 -96
- package/backend/src/utils/shell.rs +0 -19
- package/backend/src/utils/text.rs +0 -24
- package/backend/src/utils/worktree_manager.rs +0 -578
- package/backend/src/utils.rs +0 -125
- package/backend/test.db +0 -0
- package/build-npm-package.sh +0 -61
- package/dev_assets_seed/config.json +0 -19
- package/frontend/.eslintrc.json +0 -25
- package/frontend/.prettierrc.json +0 -8
- package/frontend/components.json +0 -17
- package/frontend/index.html +0 -19
- package/frontend/package-lock.json +0 -7321
- package/frontend/package.json +0 -61
- package/frontend/postcss.config.js +0 -6
- package/frontend/public/android-chrome-192x192.png +0 -0
- package/frontend/public/android-chrome-512x512.png +0 -0
- package/frontend/public/apple-touch-icon.png +0 -0
- package/frontend/public/automagik-forge-logo-dark.svg +0 -3
- package/frontend/public/automagik-forge-logo.svg +0 -3
- package/frontend/public/automagik-forge-screenshot-overview.png +0 -0
- package/frontend/public/favicon-16x16.png +0 -0
- package/frontend/public/favicon-32x32.png +0 -0
- package/frontend/public/favicon.ico +0 -0
- package/frontend/public/site.webmanifest +0 -1
- package/frontend/public/viba-kanban-favicon.png +0 -0
- package/frontend/src/App.tsx +0 -157
- package/frontend/src/components/DisclaimerDialog.tsx +0 -106
- package/frontend/src/components/GitHubLoginDialog.tsx +0 -314
- package/frontend/src/components/OnboardingDialog.tsx +0 -185
- package/frontend/src/components/PrivacyOptInDialog.tsx +0 -130
- package/frontend/src/components/ProvidePatDialog.tsx +0 -98
- package/frontend/src/components/TaskTemplateManager.tsx +0 -336
- package/frontend/src/components/config-provider.tsx +0 -119
- package/frontend/src/components/context/TaskDetailsContextProvider.tsx +0 -470
- package/frontend/src/components/context/taskDetailsContext.ts +0 -125
- package/frontend/src/components/keyboard-shortcuts-demo.tsx +0 -35
- package/frontend/src/components/layout/navbar.tsx +0 -86
- package/frontend/src/components/logo.tsx +0 -44
- package/frontend/src/components/projects/ProjectCard.tsx +0 -155
- package/frontend/src/components/projects/project-detail.tsx +0 -251
- package/frontend/src/components/projects/project-form-fields.tsx +0 -238
- package/frontend/src/components/projects/project-form.tsx +0 -301
- package/frontend/src/components/projects/project-list.tsx +0 -200
- package/frontend/src/components/projects/projects-page.tsx +0 -20
- package/frontend/src/components/tasks/BranchSelector.tsx +0 -169
- package/frontend/src/components/tasks/DeleteFileConfirmationDialog.tsx +0 -94
- package/frontend/src/components/tasks/EditorSelectionDialog.tsx +0 -119
- package/frontend/src/components/tasks/TaskCard.tsx +0 -154
- package/frontend/src/components/tasks/TaskDetails/CollapsibleToolbar.tsx +0 -33
- package/frontend/src/components/tasks/TaskDetails/DiffCard.tsx +0 -109
- package/frontend/src/components/tasks/TaskDetails/DiffChunkSection.tsx +0 -135
- package/frontend/src/components/tasks/TaskDetails/DiffFile.tsx +0 -296
- package/frontend/src/components/tasks/TaskDetails/DiffTab.tsx +0 -32
- package/frontend/src/components/tasks/TaskDetails/DisplayConversationEntry.tsx +0 -392
- package/frontend/src/components/tasks/TaskDetails/LogsTab/Conversation.tsx +0 -256
- package/frontend/src/components/tasks/TaskDetails/LogsTab/ConversationEntry.tsx +0 -56
- package/frontend/src/components/tasks/TaskDetails/LogsTab/NormalizedConversationViewer.tsx +0 -92
- package/frontend/src/components/tasks/TaskDetails/LogsTab/Prompt.tsx +0 -22
- package/frontend/src/components/tasks/TaskDetails/LogsTab/SetupScriptRunning.tsx +0 -49
- package/frontend/src/components/tasks/TaskDetails/LogsTab.tsx +0 -186
- package/frontend/src/components/tasks/TaskDetails/ProcessesTab.tsx +0 -288
- package/frontend/src/components/tasks/TaskDetails/RelatedTasksTab.tsx +0 -216
- package/frontend/src/components/tasks/TaskDetails/TabNavigation.tsx +0 -93
- package/frontend/src/components/tasks/TaskDetailsHeader.tsx +0 -169
- package/frontend/src/components/tasks/TaskDetailsPanel.tsx +0 -126
- package/frontend/src/components/tasks/TaskDetailsToolbar.tsx +0 -302
- package/frontend/src/components/tasks/TaskFollowUpSection.tsx +0 -130
- package/frontend/src/components/tasks/TaskFormDialog.tsx +0 -400
- package/frontend/src/components/tasks/TaskKanbanBoard.tsx +0 -180
- package/frontend/src/components/tasks/Toolbar/CreateAttempt.tsx +0 -259
- package/frontend/src/components/tasks/Toolbar/CreatePRDialog.tsx +0 -243
- package/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx +0 -899
- package/frontend/src/components/tasks/index.ts +0 -2
- package/frontend/src/components/theme-provider.tsx +0 -82
- package/frontend/src/components/theme-toggle.tsx +0 -36
- package/frontend/src/components/ui/alert.tsx +0 -59
- package/frontend/src/components/ui/auto-expanding-textarea.tsx +0 -70
- package/frontend/src/components/ui/badge.tsx +0 -36
- package/frontend/src/components/ui/button.tsx +0 -56
- package/frontend/src/components/ui/card.tsx +0 -86
- package/frontend/src/components/ui/checkbox.tsx +0 -44
- package/frontend/src/components/ui/chip.tsx +0 -25
- package/frontend/src/components/ui/dialog.tsx +0 -124
- package/frontend/src/components/ui/dropdown-menu.tsx +0 -198
- package/frontend/src/components/ui/file-search-textarea.tsx +0 -292
- package/frontend/src/components/ui/folder-picker.tsx +0 -279
- package/frontend/src/components/ui/input.tsx +0 -25
- package/frontend/src/components/ui/label.tsx +0 -24
- package/frontend/src/components/ui/loader.tsx +0 -26
- package/frontend/src/components/ui/markdown-renderer.tsx +0 -75
- package/frontend/src/components/ui/select.tsx +0 -160
- package/frontend/src/components/ui/separator.tsx +0 -31
- package/frontend/src/components/ui/shadcn-io/kanban/index.tsx +0 -185
- package/frontend/src/components/ui/table.tsx +0 -117
- package/frontend/src/components/ui/tabs.tsx +0 -53
- package/frontend/src/components/ui/textarea.tsx +0 -22
- package/frontend/src/components/ui/tooltip.tsx +0 -28
- package/frontend/src/hooks/useNormalizedConversation.ts +0 -440
- package/frontend/src/index.css +0 -225
- package/frontend/src/lib/api.ts +0 -630
- package/frontend/src/lib/keyboard-shortcuts.ts +0 -266
- package/frontend/src/lib/responsive-config.ts +0 -70
- package/frontend/src/lib/types.ts +0 -39
- package/frontend/src/lib/utils.ts +0 -10
- package/frontend/src/main.tsx +0 -50
- package/frontend/src/pages/McpServers.tsx +0 -418
- package/frontend/src/pages/Settings.tsx +0 -610
- package/frontend/src/pages/project-tasks.tsx +0 -575
- package/frontend/src/pages/projects.tsx +0 -18
- package/frontend/src/vite-env.d.ts +0 -1
- package/frontend/tailwind.config.js +0 -125
- package/frontend/tsconfig.json +0 -26
- package/frontend/tsconfig.node.json +0 -10
- package/frontend/vite.config.ts +0 -33
- package/npx-cli/README.md +0 -159
- package/npx-cli/automagik-forge-0.1.0.tgz +0 -0
- package/npx-cli/automagik-forge-0.1.10.tgz +0 -0
- package/npx-cli/package.json +0 -17
- package/npx-cli/vibe-kanban-0.0.55.tgz +0 -0
- package/pnpm-workspace.yaml +0 -2
- package/rust-toolchain.toml +0 -11
- package/rustfmt.toml +0 -3
- package/scripts/load-env.js +0 -43
- package/scripts/mcp_test.js +0 -374
- package/scripts/prepare-db.js +0 -45
- package/scripts/setup-dev-environment.js +0 -274
- package/scripts/start-mcp-sse.js +0 -70
- package/scripts/test-debug.js +0 -32
- package/scripts/test-mcp-sse.js +0 -138
- package/scripts/test-simple.js +0 -44
- package/scripts/test-wish-final.js +0 -179
- package/scripts/test-wish-system.js +0 -221
- package/shared/types.ts +0 -182
- package/test-npm-package.sh +0 -42
- /package/{npx-cli/bin → bin}/cli.js +0 -0
@@ -1,1321 +0,0 @@
|
|
1
|
-
use std::path::{Path, PathBuf};
|
2
|
-
|
3
|
-
use git2::{
|
4
|
-
build::CheckoutBuilder, BranchType, CherrypickOptions, Cred, DiffOptions, Error as GitError,
|
5
|
-
FetchOptions, RemoteCallbacks, Repository, WorktreeAddOptions,
|
6
|
-
};
|
7
|
-
use regex;
|
8
|
-
use tracing::{debug, info};
|
9
|
-
|
10
|
-
use crate::{
|
11
|
-
models::task_attempt::{DiffChunk, DiffChunkType, FileDiff, WorktreeDiff},
|
12
|
-
utils::worktree_manager::WorktreeManager,
|
13
|
-
};
|
14
|
-
|
15
|
-
#[derive(Debug)]
|
16
|
-
pub enum GitServiceError {
|
17
|
-
Git(GitError),
|
18
|
-
IoError(std::io::Error),
|
19
|
-
InvalidRepository(String),
|
20
|
-
BranchNotFound(String),
|
21
|
-
|
22
|
-
MergeConflicts(String),
|
23
|
-
InvalidPath(String),
|
24
|
-
WorktreeDirty(String),
|
25
|
-
}
|
26
|
-
|
27
|
-
impl std::fmt::Display for GitServiceError {
|
28
|
-
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
29
|
-
match self {
|
30
|
-
GitServiceError::Git(e) => write!(f, "Git error: {}", e),
|
31
|
-
GitServiceError::IoError(e) => write!(f, "IO error: {}", e),
|
32
|
-
GitServiceError::InvalidRepository(e) => write!(f, "Invalid repository: {}", e),
|
33
|
-
GitServiceError::BranchNotFound(e) => write!(f, "Branch not found: {}", e),
|
34
|
-
|
35
|
-
GitServiceError::MergeConflicts(e) => write!(f, "Merge conflicts: {}", e),
|
36
|
-
GitServiceError::InvalidPath(e) => write!(f, "Invalid path: {}", e),
|
37
|
-
GitServiceError::WorktreeDirty(e) => {
|
38
|
-
write!(f, "Worktree has uncommitted changes: {}", e)
|
39
|
-
}
|
40
|
-
}
|
41
|
-
}
|
42
|
-
}
|
43
|
-
|
44
|
-
impl std::error::Error for GitServiceError {}
|
45
|
-
|
46
|
-
impl From<GitError> for GitServiceError {
|
47
|
-
fn from(err: GitError) -> Self {
|
48
|
-
GitServiceError::Git(err)
|
49
|
-
}
|
50
|
-
}
|
51
|
-
|
52
|
-
impl From<std::io::Error> for GitServiceError {
|
53
|
-
fn from(err: std::io::Error) -> Self {
|
54
|
-
GitServiceError::IoError(err)
|
55
|
-
}
|
56
|
-
}
|
57
|
-
|
58
|
-
/// Service for managing Git operations in task execution workflows
|
59
|
-
pub struct GitService {
|
60
|
-
repo_path: PathBuf,
|
61
|
-
}
|
62
|
-
|
63
|
-
impl GitService {
|
64
|
-
/// Create a new GitService for the given repository path
|
65
|
-
pub fn new<P: AsRef<Path>>(repo_path: P) -> Result<Self, GitServiceError> {
|
66
|
-
let repo_path = repo_path.as_ref().to_path_buf();
|
67
|
-
|
68
|
-
// Validate that the path exists and is a git repository
|
69
|
-
if !repo_path.exists() {
|
70
|
-
return Err(GitServiceError::InvalidPath(format!(
|
71
|
-
"Repository path does not exist: {}",
|
72
|
-
repo_path.display()
|
73
|
-
)));
|
74
|
-
}
|
75
|
-
|
76
|
-
// Try to open the repository to validate it
|
77
|
-
Repository::open(&repo_path).map_err(|e| {
|
78
|
-
GitServiceError::InvalidRepository(format!(
|
79
|
-
"Failed to open repository at {}: {}",
|
80
|
-
repo_path.display(),
|
81
|
-
e
|
82
|
-
))
|
83
|
-
})?;
|
84
|
-
|
85
|
-
Ok(Self { repo_path })
|
86
|
-
}
|
87
|
-
|
88
|
-
/// Open the repository
|
89
|
-
fn open_repo(&self) -> Result<Repository, GitServiceError> {
|
90
|
-
Repository::open(&self.repo_path).map_err(GitServiceError::from)
|
91
|
-
}
|
92
|
-
|
93
|
-
/// Create a worktree with a new branch
|
94
|
-
pub fn create_worktree(
|
95
|
-
&self,
|
96
|
-
branch_name: &str,
|
97
|
-
worktree_path: &Path,
|
98
|
-
base_branch: Option<&str>,
|
99
|
-
) -> Result<(), GitServiceError> {
|
100
|
-
let repo = self.open_repo()?;
|
101
|
-
|
102
|
-
// Ensure parent directory exists
|
103
|
-
if let Some(parent) = worktree_path.parent() {
|
104
|
-
std::fs::create_dir_all(parent)?;
|
105
|
-
}
|
106
|
-
|
107
|
-
// Choose base reference
|
108
|
-
let base_reference = if let Some(base_branch) = base_branch {
|
109
|
-
let branch = repo
|
110
|
-
.find_branch(base_branch, BranchType::Local)
|
111
|
-
.map_err(|_| GitServiceError::BranchNotFound(base_branch.to_string()))?;
|
112
|
-
branch.into_reference()
|
113
|
-
} else {
|
114
|
-
// Handle new repositories without any commits
|
115
|
-
match repo.head() {
|
116
|
-
Ok(head_ref) => head_ref,
|
117
|
-
Err(e)
|
118
|
-
if e.class() == git2::ErrorClass::Reference
|
119
|
-
&& e.code() == git2::ErrorCode::UnbornBranch =>
|
120
|
-
{
|
121
|
-
// Repository has no commits yet, create an initial commit
|
122
|
-
self.create_initial_commit(&repo)?;
|
123
|
-
repo.find_reference("refs/heads/main")?
|
124
|
-
}
|
125
|
-
Err(e) => return Err(e.into()),
|
126
|
-
}
|
127
|
-
};
|
128
|
-
|
129
|
-
// Create branch
|
130
|
-
repo.branch(branch_name, &base_reference.peel_to_commit()?, false)?;
|
131
|
-
|
132
|
-
let branch = repo.find_branch(branch_name, BranchType::Local)?;
|
133
|
-
let branch_ref = branch.into_reference();
|
134
|
-
let mut worktree_opts = WorktreeAddOptions::new();
|
135
|
-
worktree_opts.reference(Some(&branch_ref));
|
136
|
-
|
137
|
-
// Create the worktree at the specified path
|
138
|
-
repo.worktree(branch_name, worktree_path, Some(&worktree_opts))?;
|
139
|
-
|
140
|
-
// Fix commondir for Windows/WSL compatibility
|
141
|
-
let worktree_name = worktree_path
|
142
|
-
.file_name()
|
143
|
-
.and_then(|n| n.to_str())
|
144
|
-
.unwrap_or(branch_name);
|
145
|
-
if let Err(e) =
|
146
|
-
WorktreeManager::fix_worktree_commondir_for_windows_wsl(&self.repo_path, worktree_name)
|
147
|
-
{
|
148
|
-
tracing::warn!("Failed to fix worktree commondir for Windows/WSL: {}", e);
|
149
|
-
}
|
150
|
-
|
151
|
-
info!(
|
152
|
-
"Created worktree '{}' at path: {}",
|
153
|
-
branch_name,
|
154
|
-
worktree_path.display()
|
155
|
-
);
|
156
|
-
Ok(())
|
157
|
-
}
|
158
|
-
|
159
|
-
/// Create an initial commit for empty repositories
|
160
|
-
fn create_initial_commit(&self, repo: &Repository) -> Result<(), GitServiceError> {
|
161
|
-
let signature = repo.signature().unwrap_or_else(|_| {
|
162
|
-
// Fallback if no Git config is set
|
163
|
-
git2::Signature::now("Automagik Forge", "noreply@automagikforge.com")
|
164
|
-
.expect("Failed to create fallback signature")
|
165
|
-
});
|
166
|
-
|
167
|
-
let tree_id = {
|
168
|
-
let tree_builder = repo.treebuilder(None)?;
|
169
|
-
tree_builder.write()?
|
170
|
-
};
|
171
|
-
let tree = repo.find_tree(tree_id)?;
|
172
|
-
|
173
|
-
// Create initial commit on main branch
|
174
|
-
let _commit_id = repo.commit(
|
175
|
-
Some("refs/heads/main"),
|
176
|
-
&signature,
|
177
|
-
&signature,
|
178
|
-
"Initial commit",
|
179
|
-
&tree,
|
180
|
-
&[],
|
181
|
-
)?;
|
182
|
-
|
183
|
-
// Set HEAD to point to main branch
|
184
|
-
repo.set_head("refs/heads/main")?;
|
185
|
-
|
186
|
-
info!("Created initial commit for empty repository");
|
187
|
-
Ok(())
|
188
|
-
}
|
189
|
-
|
190
|
-
/// Merge changes from a worktree branch back to the main repository
|
191
|
-
pub fn merge_changes(
|
192
|
-
&self,
|
193
|
-
worktree_path: &Path,
|
194
|
-
branch_name: &str,
|
195
|
-
base_branch_name: &str,
|
196
|
-
commit_message: &str,
|
197
|
-
) -> Result<String, GitServiceError> {
|
198
|
-
// Open the worktree repository
|
199
|
-
let worktree_repo = Repository::open(worktree_path)?;
|
200
|
-
|
201
|
-
// Check if worktree is dirty before proceeding
|
202
|
-
self.check_worktree_clean(&worktree_repo)?;
|
203
|
-
|
204
|
-
// Verify the task branch exists in the worktree
|
205
|
-
let task_branch = worktree_repo
|
206
|
-
.find_branch(branch_name, BranchType::Local)
|
207
|
-
.map_err(|_| GitServiceError::BranchNotFound(branch_name.to_string()))?;
|
208
|
-
|
209
|
-
// Get the base branch from the worktree
|
210
|
-
let base_branch = worktree_repo
|
211
|
-
.find_branch(base_branch_name, BranchType::Local)
|
212
|
-
.map_err(|_| GitServiceError::BranchNotFound(base_branch_name.to_string()))?;
|
213
|
-
|
214
|
-
// Get commits
|
215
|
-
let base_commit = base_branch.get().peel_to_commit()?;
|
216
|
-
let task_commit = task_branch.get().peel_to_commit()?;
|
217
|
-
|
218
|
-
// Get the signature for the merge commit
|
219
|
-
let signature = worktree_repo.signature()?;
|
220
|
-
|
221
|
-
// Perform a squash merge - create a single commit with all changes
|
222
|
-
let squash_commit_id = self.perform_squash_merge(
|
223
|
-
&worktree_repo,
|
224
|
-
&base_commit,
|
225
|
-
&task_commit,
|
226
|
-
&signature,
|
227
|
-
commit_message,
|
228
|
-
base_branch_name,
|
229
|
-
)?;
|
230
|
-
|
231
|
-
// Fix: Update main repo's HEAD if it's pointing to the base branch
|
232
|
-
let main_repo = self.open_repo()?;
|
233
|
-
let refname = format!("refs/heads/{}", base_branch_name);
|
234
|
-
|
235
|
-
if let Ok(main_head) = main_repo.head() {
|
236
|
-
if let Some(branch_name) = main_head.shorthand() {
|
237
|
-
if branch_name == base_branch_name {
|
238
|
-
// Only update main repo's HEAD if it's currently on the base branch
|
239
|
-
main_repo.set_head(&refname)?;
|
240
|
-
let mut co = CheckoutBuilder::new();
|
241
|
-
co.force();
|
242
|
-
main_repo.checkout_head(Some(&mut co))?;
|
243
|
-
}
|
244
|
-
}
|
245
|
-
}
|
246
|
-
|
247
|
-
info!("Created squash merge commit: {}", squash_commit_id);
|
248
|
-
Ok(squash_commit_id.to_string())
|
249
|
-
}
|
250
|
-
|
251
|
-
/// Check if the worktree is clean (no uncommitted changes to tracked files)
|
252
|
-
fn check_worktree_clean(&self, repo: &Repository) -> Result<(), GitServiceError> {
|
253
|
-
let mut status_options = git2::StatusOptions::new();
|
254
|
-
status_options
|
255
|
-
.include_untracked(false) // Don't include untracked files
|
256
|
-
.include_ignored(false); // Don't include ignored files
|
257
|
-
|
258
|
-
let statuses = repo.statuses(Some(&mut status_options))?;
|
259
|
-
|
260
|
-
if !statuses.is_empty() {
|
261
|
-
let mut dirty_files = Vec::new();
|
262
|
-
for entry in statuses.iter() {
|
263
|
-
let status = entry.status();
|
264
|
-
// Only consider files that are actually tracked and modified
|
265
|
-
if status.intersects(
|
266
|
-
git2::Status::INDEX_MODIFIED
|
267
|
-
| git2::Status::INDEX_NEW
|
268
|
-
| git2::Status::INDEX_DELETED
|
269
|
-
| git2::Status::INDEX_RENAMED
|
270
|
-
| git2::Status::INDEX_TYPECHANGE
|
271
|
-
| git2::Status::WT_MODIFIED
|
272
|
-
| git2::Status::WT_DELETED
|
273
|
-
| git2::Status::WT_RENAMED
|
274
|
-
| git2::Status::WT_TYPECHANGE,
|
275
|
-
) {
|
276
|
-
if let Some(path) = entry.path() {
|
277
|
-
dirty_files.push(path.to_string());
|
278
|
-
}
|
279
|
-
}
|
280
|
-
}
|
281
|
-
|
282
|
-
if !dirty_files.is_empty() {
|
283
|
-
return Err(GitServiceError::WorktreeDirty(dirty_files.join(", ")));
|
284
|
-
}
|
285
|
-
}
|
286
|
-
|
287
|
-
Ok(())
|
288
|
-
}
|
289
|
-
|
290
|
-
/// Perform a squash merge of task branch into base branch, but fail on conflicts
|
291
|
-
fn perform_squash_merge(
|
292
|
-
&self,
|
293
|
-
repo: &Repository,
|
294
|
-
base_commit: &git2::Commit,
|
295
|
-
task_commit: &git2::Commit,
|
296
|
-
signature: &git2::Signature,
|
297
|
-
commit_message: &str,
|
298
|
-
base_branch_name: &str,
|
299
|
-
) -> Result<git2::Oid, GitServiceError> {
|
300
|
-
// Attempt an in-memory merge to detect conflicts
|
301
|
-
let merge_opts = git2::MergeOptions::new();
|
302
|
-
let mut index = repo.merge_commits(base_commit, task_commit, Some(&merge_opts))?;
|
303
|
-
|
304
|
-
// If there are conflicts, return an error
|
305
|
-
if index.has_conflicts() {
|
306
|
-
return Err(GitServiceError::MergeConflicts(
|
307
|
-
"Merge failed due to conflicts. Please resolve conflicts manually.".to_string(),
|
308
|
-
));
|
309
|
-
}
|
310
|
-
|
311
|
-
// Write the merged tree back to the repository
|
312
|
-
let tree_id = index.write_tree_to(repo)?;
|
313
|
-
let tree = repo.find_tree(tree_id)?;
|
314
|
-
|
315
|
-
// Create a squash commit: use merged tree with base_commit as sole parent
|
316
|
-
let squash_commit_id = repo.commit(
|
317
|
-
None, // Don't update any reference yet
|
318
|
-
signature, // Author
|
319
|
-
signature, // Committer
|
320
|
-
commit_message, // Custom message
|
321
|
-
&tree, // Merged tree content
|
322
|
-
&[base_commit], // Single parent: base branch commit
|
323
|
-
)?;
|
324
|
-
|
325
|
-
// Update the base branch reference to point to the new commit
|
326
|
-
let refname = format!("refs/heads/{}", base_branch_name);
|
327
|
-
repo.reference(&refname, squash_commit_id, true, "Squash merge")?;
|
328
|
-
|
329
|
-
Ok(squash_commit_id)
|
330
|
-
}
|
331
|
-
|
332
|
-
/// Rebase a worktree branch onto a new base
|
333
|
-
pub fn rebase_branch(
|
334
|
-
&self,
|
335
|
-
worktree_path: &Path,
|
336
|
-
new_base_branch: Option<&str>,
|
337
|
-
old_base_branch: &str,
|
338
|
-
) -> Result<String, GitServiceError> {
|
339
|
-
let worktree_repo = Repository::open(worktree_path)?;
|
340
|
-
let main_repo = self.open_repo()?;
|
341
|
-
|
342
|
-
// Check if there's an existing rebase in progress and abort it
|
343
|
-
let state = worktree_repo.state();
|
344
|
-
if state == git2::RepositoryState::Rebase
|
345
|
-
|| state == git2::RepositoryState::RebaseInteractive
|
346
|
-
|| state == git2::RepositoryState::RebaseMerge
|
347
|
-
{
|
348
|
-
tracing::warn!("Existing rebase in progress, aborting it first");
|
349
|
-
// Try to abort the existing rebase
|
350
|
-
if let Ok(mut existing_rebase) = worktree_repo.open_rebase(None) {
|
351
|
-
let _ = existing_rebase.abort();
|
352
|
-
}
|
353
|
-
}
|
354
|
-
|
355
|
-
// Get the target base branch reference
|
356
|
-
let base_branch_name = match new_base_branch {
|
357
|
-
Some(branch) => branch.to_string(),
|
358
|
-
None => main_repo
|
359
|
-
.head()
|
360
|
-
.ok()
|
361
|
-
.and_then(|head| head.shorthand().map(|s| s.to_string()))
|
362
|
-
.unwrap_or_else(|| "main".to_string()),
|
363
|
-
};
|
364
|
-
let base_branch_name = base_branch_name.as_str();
|
365
|
-
|
366
|
-
// Handle remote branches by fetching them first and creating/updating local tracking branches
|
367
|
-
let local_branch_name = if base_branch_name.starts_with("origin/") {
|
368
|
-
// This is a remote branch, fetch it and create/update local tracking branch
|
369
|
-
let remote_branch_name = base_branch_name.strip_prefix("origin/").unwrap();
|
370
|
-
|
371
|
-
// First, fetch the latest changes from remote
|
372
|
-
self.fetch_from_remote(&main_repo)?;
|
373
|
-
|
374
|
-
// Try to find the remote branch after fetch
|
375
|
-
let remote_branch = main_repo
|
376
|
-
.find_branch(base_branch_name, BranchType::Remote)
|
377
|
-
.map_err(|_| GitServiceError::BranchNotFound(base_branch_name.to_string()))?;
|
378
|
-
|
379
|
-
// Check if local tracking branch exists
|
380
|
-
match main_repo.find_branch(remote_branch_name, BranchType::Local) {
|
381
|
-
Ok(mut local_branch) => {
|
382
|
-
// Local tracking branch exists, update it to match remote
|
383
|
-
let remote_commit = remote_branch.get().peel_to_commit()?;
|
384
|
-
local_branch
|
385
|
-
.get_mut()
|
386
|
-
.set_target(remote_commit.id(), "Update local branch to match remote")?;
|
387
|
-
}
|
388
|
-
Err(_) => {
|
389
|
-
// Local tracking branch doesn't exist, create it
|
390
|
-
let remote_commit = remote_branch.get().peel_to_commit()?;
|
391
|
-
main_repo.branch(remote_branch_name, &remote_commit, false)?;
|
392
|
-
}
|
393
|
-
}
|
394
|
-
|
395
|
-
// Use the local branch name for rebase
|
396
|
-
remote_branch_name
|
397
|
-
} else {
|
398
|
-
// This is already a local branch
|
399
|
-
base_branch_name
|
400
|
-
};
|
401
|
-
|
402
|
-
// Get the local branch for rebase
|
403
|
-
let base_branch = main_repo
|
404
|
-
.find_branch(local_branch_name, BranchType::Local)
|
405
|
-
.map_err(|_| GitServiceError::BranchNotFound(local_branch_name.to_string()))?;
|
406
|
-
|
407
|
-
let new_base_commit_id = base_branch.get().peel_to_commit()?.id();
|
408
|
-
|
409
|
-
// Get the HEAD commit of the worktree (the changes to rebase)
|
410
|
-
let head = worktree_repo.head()?;
|
411
|
-
let task_branch_commit_id = head.peel_to_commit()?.id();
|
412
|
-
|
413
|
-
let signature = worktree_repo.signature()?;
|
414
|
-
|
415
|
-
// Find the old base branch
|
416
|
-
let old_base_branch_ref = if old_base_branch.starts_with("origin/") {
|
417
|
-
// Remote branch - get local tracking branch name
|
418
|
-
let remote_branch_name = old_base_branch.strip_prefix("origin/").unwrap();
|
419
|
-
main_repo
|
420
|
-
.find_branch(remote_branch_name, BranchType::Local)
|
421
|
-
.map_err(|_| GitServiceError::BranchNotFound(remote_branch_name.to_string()))?
|
422
|
-
} else {
|
423
|
-
// Local branch
|
424
|
-
main_repo
|
425
|
-
.find_branch(old_base_branch, BranchType::Local)
|
426
|
-
.map_err(|_| GitServiceError::BranchNotFound(old_base_branch.to_string()))?
|
427
|
-
};
|
428
|
-
|
429
|
-
let old_base_commit_id = old_base_branch_ref.get().peel_to_commit()?.id();
|
430
|
-
|
431
|
-
// Find commits unique to the task branch
|
432
|
-
let unique_commits = Self::find_unique_commits(
|
433
|
-
&worktree_repo,
|
434
|
-
task_branch_commit_id,
|
435
|
-
old_base_commit_id,
|
436
|
-
new_base_commit_id,
|
437
|
-
)?;
|
438
|
-
|
439
|
-
if !unique_commits.is_empty() {
|
440
|
-
// Reset HEAD to the new base branch
|
441
|
-
let new_base_commit = worktree_repo.find_commit(new_base_commit_id)?;
|
442
|
-
worktree_repo.reset(new_base_commit.as_object(), git2::ResetType::Hard, None)?;
|
443
|
-
|
444
|
-
// Cherry-pick the unique commits
|
445
|
-
Self::cherry_pick_commits(&worktree_repo, &unique_commits, &signature)?;
|
446
|
-
} else {
|
447
|
-
// No unique commits to rebase, just reset to new base
|
448
|
-
let new_base_commit = worktree_repo.find_commit(new_base_commit_id)?;
|
449
|
-
worktree_repo.reset(new_base_commit.as_object(), git2::ResetType::Hard, None)?;
|
450
|
-
}
|
451
|
-
|
452
|
-
// Get the final commit ID after rebase
|
453
|
-
let final_head = worktree_repo.head()?;
|
454
|
-
let final_commit = final_head.peel_to_commit()?;
|
455
|
-
|
456
|
-
info!("Rebase completed. New HEAD: {}", final_commit.id());
|
457
|
-
Ok(final_commit.id().to_string())
|
458
|
-
}
|
459
|
-
|
460
|
-
/// Get enhanced diff for task attempts (from merge commit or worktree)
|
461
|
-
pub fn get_enhanced_diff(
|
462
|
-
&self,
|
463
|
-
worktree_path: &Path,
|
464
|
-
merge_commit_id: Option<&str>,
|
465
|
-
base_branch: &str,
|
466
|
-
) -> Result<WorktreeDiff, GitServiceError> {
|
467
|
-
let mut files = Vec::new();
|
468
|
-
|
469
|
-
if let Some(merge_commit_id) = merge_commit_id {
|
470
|
-
// Task attempt has been merged - show the diff from the merge commit
|
471
|
-
self.get_merged_diff(merge_commit_id, &mut files)?;
|
472
|
-
} else {
|
473
|
-
// Task attempt not yet merged - get worktree diff
|
474
|
-
self.get_worktree_diff(worktree_path, base_branch, &mut files)?;
|
475
|
-
}
|
476
|
-
|
477
|
-
Ok(WorktreeDiff { files })
|
478
|
-
}
|
479
|
-
|
480
|
-
/// Get diff from a merge commit
|
481
|
-
fn get_merged_diff(
|
482
|
-
&self,
|
483
|
-
merge_commit_id: &str,
|
484
|
-
files: &mut Vec<FileDiff>,
|
485
|
-
) -> Result<(), GitServiceError> {
|
486
|
-
let main_repo = self.open_repo()?;
|
487
|
-
let merge_commit = main_repo.find_commit(git2::Oid::from_str(merge_commit_id)?)?;
|
488
|
-
|
489
|
-
// A merge commit has multiple parents - first parent is the main branch before merge,
|
490
|
-
// second parent is the branch that was merged
|
491
|
-
let parents: Vec<_> = merge_commit.parents().collect();
|
492
|
-
|
493
|
-
// Create diff options with more context
|
494
|
-
let mut diff_opts = DiffOptions::new();
|
495
|
-
diff_opts.context_lines(10);
|
496
|
-
diff_opts.interhunk_lines(0);
|
497
|
-
|
498
|
-
let diff = if parents.len() >= 2 {
|
499
|
-
let base_tree = parents[0].tree()?;
|
500
|
-
let merged_tree = parents[1].tree()?;
|
501
|
-
main_repo.diff_tree_to_tree(
|
502
|
-
Some(&base_tree),
|
503
|
-
Some(&merged_tree),
|
504
|
-
Some(&mut diff_opts),
|
505
|
-
)?
|
506
|
-
} else {
|
507
|
-
// Fast-forward merge or single parent
|
508
|
-
let base_tree = if !parents.is_empty() {
|
509
|
-
parents[0].tree()?
|
510
|
-
} else {
|
511
|
-
main_repo.find_tree(git2::Oid::zero())?
|
512
|
-
};
|
513
|
-
let merged_tree = merge_commit.tree()?;
|
514
|
-
main_repo.diff_tree_to_tree(
|
515
|
-
Some(&base_tree),
|
516
|
-
Some(&merged_tree),
|
517
|
-
Some(&mut diff_opts),
|
518
|
-
)?
|
519
|
-
};
|
520
|
-
|
521
|
-
// Process each diff delta
|
522
|
-
diff.foreach(
|
523
|
-
&mut |delta, _progress| {
|
524
|
-
if let Some(path_str) = delta.new_file().path().and_then(|p| p.to_str()) {
|
525
|
-
let old_file = delta.old_file();
|
526
|
-
let new_file = delta.new_file();
|
527
|
-
|
528
|
-
if let Ok(diff_chunks) =
|
529
|
-
self.generate_git_diff_chunks(&main_repo, &old_file, &new_file, path_str)
|
530
|
-
{
|
531
|
-
if !diff_chunks.is_empty() {
|
532
|
-
files.push(FileDiff {
|
533
|
-
path: path_str.to_string(),
|
534
|
-
chunks: diff_chunks,
|
535
|
-
});
|
536
|
-
} else if delta.status() == git2::Delta::Added
|
537
|
-
|| delta.status() == git2::Delta::Deleted
|
538
|
-
{
|
539
|
-
files.push(FileDiff {
|
540
|
-
path: path_str.to_string(),
|
541
|
-
chunks: vec![DiffChunk {
|
542
|
-
chunk_type: if delta.status() == git2::Delta::Added {
|
543
|
-
DiffChunkType::Insert
|
544
|
-
} else {
|
545
|
-
DiffChunkType::Delete
|
546
|
-
},
|
547
|
-
content: format!(
|
548
|
-
"{} file",
|
549
|
-
if delta.status() == git2::Delta::Added {
|
550
|
-
"Added"
|
551
|
-
} else {
|
552
|
-
"Deleted"
|
553
|
-
}
|
554
|
-
),
|
555
|
-
}],
|
556
|
-
});
|
557
|
-
}
|
558
|
-
}
|
559
|
-
}
|
560
|
-
true
|
561
|
-
},
|
562
|
-
None,
|
563
|
-
None,
|
564
|
-
None,
|
565
|
-
)?;
|
566
|
-
|
567
|
-
Ok(())
|
568
|
-
}
|
569
|
-
|
570
|
-
/// Get diff for a worktree (before merge)
|
571
|
-
fn get_worktree_diff(
|
572
|
-
&self,
|
573
|
-
worktree_path: &Path,
|
574
|
-
base_branch: &str,
|
575
|
-
files: &mut Vec<FileDiff>,
|
576
|
-
) -> Result<(), GitServiceError> {
|
577
|
-
let worktree_repo = Repository::open(worktree_path)?;
|
578
|
-
let main_repo = self.open_repo()?;
|
579
|
-
|
580
|
-
// Get the base branch commit
|
581
|
-
let base_branch_ref = main_repo
|
582
|
-
.find_branch(base_branch, BranchType::Local)
|
583
|
-
.map_err(|_| GitServiceError::BranchNotFound(base_branch.to_string()))?;
|
584
|
-
let base_branch_oid = base_branch_ref.get().peel_to_commit()?.id();
|
585
|
-
|
586
|
-
// Get the current worktree HEAD commit
|
587
|
-
let worktree_head = worktree_repo.head()?;
|
588
|
-
let worktree_head_oid = worktree_head.peel_to_commit()?.id();
|
589
|
-
|
590
|
-
// Find the merge base (common ancestor) between the base branch and worktree head
|
591
|
-
let base_oid = worktree_repo.merge_base(base_branch_oid, worktree_head_oid)?;
|
592
|
-
let base_commit = worktree_repo.find_commit(base_oid)?;
|
593
|
-
let base_tree = base_commit.tree()?;
|
594
|
-
|
595
|
-
// Get the current tree from the worktree HEAD commit
|
596
|
-
let current_commit = worktree_repo.find_commit(worktree_head_oid)?;
|
597
|
-
let current_tree = current_commit.tree()?;
|
598
|
-
|
599
|
-
// Create a diff between the base tree and current tree
|
600
|
-
let mut diff_opts = DiffOptions::new();
|
601
|
-
diff_opts.context_lines(10);
|
602
|
-
diff_opts.interhunk_lines(0);
|
603
|
-
|
604
|
-
let diff = worktree_repo.diff_tree_to_tree(
|
605
|
-
Some(&base_tree),
|
606
|
-
Some(¤t_tree),
|
607
|
-
Some(&mut diff_opts),
|
608
|
-
)?;
|
609
|
-
|
610
|
-
// Process committed changes
|
611
|
-
diff.foreach(
|
612
|
-
&mut |delta, _progress| {
|
613
|
-
if let Some(path_str) = delta.new_file().path().and_then(|p| p.to_str()) {
|
614
|
-
let old_file = delta.old_file();
|
615
|
-
let new_file = delta.new_file();
|
616
|
-
|
617
|
-
if let Ok(diff_chunks) = self.generate_git_diff_chunks(
|
618
|
-
&worktree_repo,
|
619
|
-
&old_file,
|
620
|
-
&new_file,
|
621
|
-
path_str,
|
622
|
-
) {
|
623
|
-
if !diff_chunks.is_empty() {
|
624
|
-
files.push(FileDiff {
|
625
|
-
path: path_str.to_string(),
|
626
|
-
chunks: diff_chunks,
|
627
|
-
});
|
628
|
-
} else if delta.status() == git2::Delta::Added
|
629
|
-
|| delta.status() == git2::Delta::Deleted
|
630
|
-
{
|
631
|
-
files.push(FileDiff {
|
632
|
-
path: path_str.to_string(),
|
633
|
-
chunks: vec![DiffChunk {
|
634
|
-
chunk_type: if delta.status() == git2::Delta::Added {
|
635
|
-
DiffChunkType::Insert
|
636
|
-
} else {
|
637
|
-
DiffChunkType::Delete
|
638
|
-
},
|
639
|
-
content: format!(
|
640
|
-
"{} file",
|
641
|
-
if delta.status() == git2::Delta::Added {
|
642
|
-
"Added"
|
643
|
-
} else {
|
644
|
-
"Deleted"
|
645
|
-
}
|
646
|
-
),
|
647
|
-
}],
|
648
|
-
});
|
649
|
-
}
|
650
|
-
}
|
651
|
-
}
|
652
|
-
true
|
653
|
-
},
|
654
|
-
None,
|
655
|
-
None,
|
656
|
-
None,
|
657
|
-
)?;
|
658
|
-
|
659
|
-
// Also get unstaged changes (working directory changes)
|
660
|
-
let current_tree = worktree_repo.head()?.peel_to_tree()?;
|
661
|
-
|
662
|
-
let mut unstaged_diff_opts = DiffOptions::new();
|
663
|
-
unstaged_diff_opts.context_lines(10);
|
664
|
-
unstaged_diff_opts.interhunk_lines(0);
|
665
|
-
unstaged_diff_opts.include_untracked(true);
|
666
|
-
|
667
|
-
let unstaged_diff = worktree_repo
|
668
|
-
.diff_tree_to_workdir_with_index(Some(¤t_tree), Some(&mut unstaged_diff_opts))?;
|
669
|
-
|
670
|
-
// Process unstaged changes
|
671
|
-
unstaged_diff.foreach(
|
672
|
-
&mut |delta, _progress| {
|
673
|
-
if let Some(path_str) = delta.new_file().path().and_then(|p| p.to_str()) {
|
674
|
-
if let Err(e) = self.process_unstaged_file(
|
675
|
-
files,
|
676
|
-
&worktree_repo,
|
677
|
-
base_oid,
|
678
|
-
worktree_path,
|
679
|
-
path_str,
|
680
|
-
&delta,
|
681
|
-
) {
|
682
|
-
eprintln!("Error processing unstaged file {}: {:?}", path_str, e);
|
683
|
-
}
|
684
|
-
}
|
685
|
-
true
|
686
|
-
},
|
687
|
-
None,
|
688
|
-
None,
|
689
|
-
None,
|
690
|
-
)?;
|
691
|
-
|
692
|
-
Ok(())
|
693
|
-
}
|
694
|
-
|
695
|
-
/// Generate diff chunks using Git's native diff algorithm
|
696
|
-
fn generate_git_diff_chunks(
|
697
|
-
&self,
|
698
|
-
repo: &Repository,
|
699
|
-
old_file: &git2::DiffFile,
|
700
|
-
new_file: &git2::DiffFile,
|
701
|
-
file_path: &str,
|
702
|
-
) -> Result<Vec<DiffChunk>, GitServiceError> {
|
703
|
-
let mut chunks = Vec::new();
|
704
|
-
|
705
|
-
// Create a patch for the single file using Git's native diff
|
706
|
-
let old_blob = if !old_file.id().is_zero() {
|
707
|
-
Some(repo.find_blob(old_file.id())?)
|
708
|
-
} else {
|
709
|
-
None
|
710
|
-
};
|
711
|
-
|
712
|
-
let new_blob = if !new_file.id().is_zero() {
|
713
|
-
Some(repo.find_blob(new_file.id())?)
|
714
|
-
} else {
|
715
|
-
None
|
716
|
-
};
|
717
|
-
|
718
|
-
// Generate patch using Git's diff algorithm
|
719
|
-
let mut diff_opts = DiffOptions::new();
|
720
|
-
diff_opts.context_lines(10);
|
721
|
-
diff_opts.interhunk_lines(0);
|
722
|
-
|
723
|
-
let patch = match (old_blob.as_ref(), new_blob.as_ref()) {
|
724
|
-
(Some(old_b), Some(new_b)) => git2::Patch::from_blobs(
|
725
|
-
old_b,
|
726
|
-
Some(Path::new(file_path)),
|
727
|
-
new_b,
|
728
|
-
Some(Path::new(file_path)),
|
729
|
-
Some(&mut diff_opts),
|
730
|
-
)?,
|
731
|
-
(None, Some(new_b)) => git2::Patch::from_buffers(
|
732
|
-
&[],
|
733
|
-
Some(Path::new(file_path)),
|
734
|
-
new_b.content(),
|
735
|
-
Some(Path::new(file_path)),
|
736
|
-
Some(&mut diff_opts),
|
737
|
-
)?,
|
738
|
-
(Some(old_b), None) => git2::Patch::from_blob_and_buffer(
|
739
|
-
old_b,
|
740
|
-
Some(Path::new(file_path)),
|
741
|
-
&[],
|
742
|
-
Some(Path::new(file_path)),
|
743
|
-
Some(&mut diff_opts),
|
744
|
-
)?,
|
745
|
-
(None, None) => {
|
746
|
-
return Ok(chunks);
|
747
|
-
}
|
748
|
-
};
|
749
|
-
|
750
|
-
// Process the patch hunks
|
751
|
-
for hunk_idx in 0..patch.num_hunks() {
|
752
|
-
let (_hunk, hunk_lines) = patch.hunk(hunk_idx)?;
|
753
|
-
|
754
|
-
for line_idx in 0..hunk_lines {
|
755
|
-
let line = patch.line_in_hunk(hunk_idx, line_idx)?;
|
756
|
-
let content = String::from_utf8_lossy(line.content()).to_string();
|
757
|
-
|
758
|
-
let chunk_type = match line.origin() {
|
759
|
-
' ' => DiffChunkType::Equal,
|
760
|
-
'+' => DiffChunkType::Insert,
|
761
|
-
'-' => DiffChunkType::Delete,
|
762
|
-
_ => continue,
|
763
|
-
};
|
764
|
-
|
765
|
-
chunks.push(DiffChunk {
|
766
|
-
chunk_type,
|
767
|
-
content,
|
768
|
-
});
|
769
|
-
}
|
770
|
-
}
|
771
|
-
|
772
|
-
Ok(chunks)
|
773
|
-
}
|
774
|
-
|
775
|
-
/// Process unstaged file changes
|
776
|
-
fn process_unstaged_file(
|
777
|
-
&self,
|
778
|
-
files: &mut Vec<FileDiff>,
|
779
|
-
worktree_repo: &Repository,
|
780
|
-
base_oid: git2::Oid,
|
781
|
-
worktree_path: &Path,
|
782
|
-
path_str: &str,
|
783
|
-
delta: &git2::DiffDelta,
|
784
|
-
) -> Result<(), GitServiceError> {
|
785
|
-
// Check if we already have a diff for this file from committed changes
|
786
|
-
if let Some(existing_file) = files.iter_mut().find(|f| f.path == path_str) {
|
787
|
-
// File already has committed changes, create a combined diff
|
788
|
-
let base_content = self.get_base_file_content(worktree_repo, base_oid, path_str)?;
|
789
|
-
let working_content = self.get_working_file_content(worktree_path, path_str, delta)?;
|
790
|
-
|
791
|
-
if base_content != working_content {
|
792
|
-
if let Ok(combined_chunks) =
|
793
|
-
self.create_combined_diff_chunks(&base_content, &working_content, path_str)
|
794
|
-
{
|
795
|
-
existing_file.chunks = combined_chunks;
|
796
|
-
}
|
797
|
-
}
|
798
|
-
} else {
|
799
|
-
// File only has unstaged changes
|
800
|
-
let base_content = self.get_base_file_content(worktree_repo, base_oid, path_str)?;
|
801
|
-
let working_content = self.get_working_file_content(worktree_path, path_str, delta)?;
|
802
|
-
|
803
|
-
if base_content != working_content || delta.status() != git2::Delta::Modified {
|
804
|
-
if let Ok(chunks) =
|
805
|
-
self.create_combined_diff_chunks(&base_content, &working_content, path_str)
|
806
|
-
{
|
807
|
-
if !chunks.is_empty() {
|
808
|
-
files.push(FileDiff {
|
809
|
-
path: path_str.to_string(),
|
810
|
-
chunks,
|
811
|
-
});
|
812
|
-
}
|
813
|
-
} else if delta.status() != git2::Delta::Modified {
|
814
|
-
// Fallback for added/deleted files
|
815
|
-
files.push(FileDiff {
|
816
|
-
path: path_str.to_string(),
|
817
|
-
chunks: vec![DiffChunk {
|
818
|
-
chunk_type: if delta.status() == git2::Delta::Added {
|
819
|
-
DiffChunkType::Insert
|
820
|
-
} else {
|
821
|
-
DiffChunkType::Delete
|
822
|
-
},
|
823
|
-
content: format!(
|
824
|
-
"{} file",
|
825
|
-
if delta.status() == git2::Delta::Added {
|
826
|
-
"Added"
|
827
|
-
} else {
|
828
|
-
"Deleted"
|
829
|
-
}
|
830
|
-
),
|
831
|
-
}],
|
832
|
-
});
|
833
|
-
}
|
834
|
-
}
|
835
|
-
}
|
836
|
-
|
837
|
-
Ok(())
|
838
|
-
}
|
839
|
-
|
840
|
-
/// Get the content of a file at the base commit
|
841
|
-
fn get_base_file_content(
|
842
|
-
&self,
|
843
|
-
repo: &Repository,
|
844
|
-
base_oid: git2::Oid,
|
845
|
-
path_str: &str,
|
846
|
-
) -> Result<String, GitServiceError> {
|
847
|
-
if let Ok(base_commit) = repo.find_commit(base_oid) {
|
848
|
-
if let Ok(base_tree) = base_commit.tree() {
|
849
|
-
if let Ok(entry) = base_tree.get_path(Path::new(path_str)) {
|
850
|
-
if let Ok(blob) = repo.find_blob(entry.id()) {
|
851
|
-
return Ok(String::from_utf8_lossy(blob.content()).to_string());
|
852
|
-
}
|
853
|
-
}
|
854
|
-
}
|
855
|
-
}
|
856
|
-
Ok(String::new())
|
857
|
-
}
|
858
|
-
|
859
|
-
/// Get the content of a file in the working directory
|
860
|
-
fn get_working_file_content(
|
861
|
-
&self,
|
862
|
-
worktree_path: &Path,
|
863
|
-
path_str: &str,
|
864
|
-
delta: &git2::DiffDelta,
|
865
|
-
) -> Result<String, GitServiceError> {
|
866
|
-
if delta.status() != git2::Delta::Deleted {
|
867
|
-
let file_path = worktree_path.join(path_str);
|
868
|
-
std::fs::read_to_string(&file_path).map_err(GitServiceError::from)
|
869
|
-
} else {
|
870
|
-
Ok(String::new())
|
871
|
-
}
|
872
|
-
}
|
873
|
-
|
874
|
-
/// Create diff chunks from two text contents
|
875
|
-
fn create_combined_diff_chunks(
|
876
|
-
&self,
|
877
|
-
old_content: &str,
|
878
|
-
new_content: &str,
|
879
|
-
path_str: &str,
|
880
|
-
) -> Result<Vec<DiffChunk>, GitServiceError> {
|
881
|
-
let mut diff_opts = DiffOptions::new();
|
882
|
-
diff_opts.context_lines(10);
|
883
|
-
diff_opts.interhunk_lines(0);
|
884
|
-
|
885
|
-
let patch = git2::Patch::from_buffers(
|
886
|
-
old_content.as_bytes(),
|
887
|
-
Some(Path::new(path_str)),
|
888
|
-
new_content.as_bytes(),
|
889
|
-
Some(Path::new(path_str)),
|
890
|
-
Some(&mut diff_opts),
|
891
|
-
)?;
|
892
|
-
|
893
|
-
let mut chunks = Vec::new();
|
894
|
-
|
895
|
-
for hunk_idx in 0..patch.num_hunks() {
|
896
|
-
let (_hunk, hunk_lines) = patch.hunk(hunk_idx)?;
|
897
|
-
|
898
|
-
for line_idx in 0..hunk_lines {
|
899
|
-
let line = patch.line_in_hunk(hunk_idx, line_idx)?;
|
900
|
-
let content = String::from_utf8_lossy(line.content()).to_string();
|
901
|
-
|
902
|
-
let chunk_type = match line.origin() {
|
903
|
-
' ' => DiffChunkType::Equal,
|
904
|
-
'+' => DiffChunkType::Insert,
|
905
|
-
'-' => DiffChunkType::Delete,
|
906
|
-
_ => continue,
|
907
|
-
};
|
908
|
-
|
909
|
-
chunks.push(DiffChunk {
|
910
|
-
chunk_type,
|
911
|
-
content,
|
912
|
-
});
|
913
|
-
}
|
914
|
-
}
|
915
|
-
|
916
|
-
Ok(chunks)
|
917
|
-
}
|
918
|
-
|
919
|
-
/// Delete a file from the repository and commit the change
|
920
|
-
pub fn delete_file_and_commit(
|
921
|
-
&self,
|
922
|
-
worktree_path: &Path,
|
923
|
-
file_path: &str,
|
924
|
-
) -> Result<String, GitServiceError> {
|
925
|
-
let repo = Repository::open(worktree_path)?;
|
926
|
-
|
927
|
-
// Get the absolute path to the file within the worktree
|
928
|
-
let file_full_path = worktree_path.join(file_path);
|
929
|
-
|
930
|
-
// Check if file exists and delete it
|
931
|
-
if file_full_path.exists() {
|
932
|
-
std::fs::remove_file(&file_full_path).map_err(|e| {
|
933
|
-
GitServiceError::IoError(std::io::Error::other(format!(
|
934
|
-
"Failed to delete file {}: {}",
|
935
|
-
file_path, e
|
936
|
-
)))
|
937
|
-
})?;
|
938
|
-
|
939
|
-
debug!("Deleted file: {}", file_path);
|
940
|
-
} else {
|
941
|
-
info!("File {} does not exist, skipping deletion", file_path);
|
942
|
-
}
|
943
|
-
|
944
|
-
// Stage the deletion
|
945
|
-
let mut index = repo.index()?;
|
946
|
-
index.remove_path(Path::new(file_path))?;
|
947
|
-
index.write()?;
|
948
|
-
|
949
|
-
// Create a commit for the file deletion
|
950
|
-
let signature = repo.signature()?;
|
951
|
-
let tree_id = index.write_tree()?;
|
952
|
-
let tree = repo.find_tree(tree_id)?;
|
953
|
-
|
954
|
-
// Get the current HEAD commit
|
955
|
-
let head = repo.head()?;
|
956
|
-
let parent_commit = head.peel_to_commit()?;
|
957
|
-
|
958
|
-
let commit_message = format!("Delete file: {}", file_path);
|
959
|
-
let commit_id = repo.commit(
|
960
|
-
Some("HEAD"),
|
961
|
-
&signature,
|
962
|
-
&signature,
|
963
|
-
&commit_message,
|
964
|
-
&tree,
|
965
|
-
&[&parent_commit],
|
966
|
-
)?;
|
967
|
-
|
968
|
-
info!("File {} deleted and committed: {}", file_path, commit_id);
|
969
|
-
|
970
|
-
Ok(commit_id.to_string())
|
971
|
-
}
|
972
|
-
|
973
|
-
/// Get the default branch name for the repository
|
974
|
-
pub fn get_default_branch_name(&self) -> Result<String, GitServiceError> {
|
975
|
-
let repo = self.open_repo()?;
|
976
|
-
|
977
|
-
let result = match repo.head() {
|
978
|
-
Ok(head_ref) => Ok(head_ref.shorthand().unwrap_or("main").to_string()),
|
979
|
-
Err(e)
|
980
|
-
if e.class() == git2::ErrorClass::Reference
|
981
|
-
&& e.code() == git2::ErrorCode::UnbornBranch =>
|
982
|
-
{
|
983
|
-
Ok("main".to_string()) // Repository has no commits yet
|
984
|
-
}
|
985
|
-
Err(_) => Ok("main".to_string()), // Fallback
|
986
|
-
};
|
987
|
-
result
|
988
|
-
}
|
989
|
-
|
990
|
-
/// Recreate a worktree from an existing branch (for cold task support)
|
991
|
-
pub async fn recreate_worktree_from_branch(
|
992
|
-
&self,
|
993
|
-
branch_name: &str,
|
994
|
-
stored_worktree_path: &Path,
|
995
|
-
) -> Result<PathBuf, GitServiceError> {
|
996
|
-
let repo = self.open_repo()?;
|
997
|
-
|
998
|
-
// Verify branch exists before proceeding
|
999
|
-
let _branch = repo
|
1000
|
-
.find_branch(branch_name, BranchType::Local)
|
1001
|
-
.map_err(|_| GitServiceError::BranchNotFound(branch_name.to_string()))?;
|
1002
|
-
drop(_branch);
|
1003
|
-
|
1004
|
-
let stored_worktree_path_str = stored_worktree_path.to_string_lossy().to_string();
|
1005
|
-
|
1006
|
-
info!(
|
1007
|
-
"Recreating worktree using stored path: {} (branch: {})",
|
1008
|
-
stored_worktree_path_str, branch_name
|
1009
|
-
);
|
1010
|
-
|
1011
|
-
// Clean up existing directory if it exists to avoid git sync issues
|
1012
|
-
if stored_worktree_path.exists() {
|
1013
|
-
debug!(
|
1014
|
-
"Removing existing directory before worktree recreation: {}",
|
1015
|
-
stored_worktree_path_str
|
1016
|
-
);
|
1017
|
-
std::fs::remove_dir_all(stored_worktree_path).map_err(|e| {
|
1018
|
-
GitServiceError::IoError(std::io::Error::other(format!(
|
1019
|
-
"Failed to remove existing worktree directory {}: {}",
|
1020
|
-
stored_worktree_path_str, e
|
1021
|
-
)))
|
1022
|
-
})?;
|
1023
|
-
}
|
1024
|
-
|
1025
|
-
// Ensure parent directory exists - critical for session continuity
|
1026
|
-
if let Some(parent) = stored_worktree_path.parent() {
|
1027
|
-
std::fs::create_dir_all(parent).map_err(|e| {
|
1028
|
-
GitServiceError::IoError(std::io::Error::other(format!(
|
1029
|
-
"Failed to create parent directory for worktree path {}: {}",
|
1030
|
-
stored_worktree_path_str, e
|
1031
|
-
)))
|
1032
|
-
})?;
|
1033
|
-
}
|
1034
|
-
|
1035
|
-
// Extract repository path for WorktreeManager
|
1036
|
-
let repo_path = repo
|
1037
|
-
.workdir()
|
1038
|
-
.ok_or_else(|| {
|
1039
|
-
GitServiceError::InvalidRepository(
|
1040
|
-
"Repository has no working directory".to_string(),
|
1041
|
-
)
|
1042
|
-
})?
|
1043
|
-
.to_str()
|
1044
|
-
.ok_or_else(|| {
|
1045
|
-
GitServiceError::InvalidRepository("Repository path is not valid UTF-8".to_string())
|
1046
|
-
})?
|
1047
|
-
.to_string();
|
1048
|
-
|
1049
|
-
WorktreeManager::ensure_worktree_exists(
|
1050
|
-
repo_path,
|
1051
|
-
branch_name.to_string(),
|
1052
|
-
stored_worktree_path.to_path_buf(),
|
1053
|
-
)
|
1054
|
-
.await
|
1055
|
-
.map_err(|e| {
|
1056
|
-
GitServiceError::IoError(std::io::Error::other(format!(
|
1057
|
-
"WorktreeManager error: {}",
|
1058
|
-
e
|
1059
|
-
)))
|
1060
|
-
})?;
|
1061
|
-
|
1062
|
-
info!(
|
1063
|
-
"Successfully recreated worktree at original path: {} -> {}",
|
1064
|
-
branch_name, stored_worktree_path_str
|
1065
|
-
);
|
1066
|
-
Ok(stored_worktree_path.to_path_buf())
|
1067
|
-
}
|
1068
|
-
|
1069
|
-
/// Extract GitHub owner and repo name from git repo path
|
1070
|
-
pub fn get_github_repo_info(&self) -> Result<(String, String), GitServiceError> {
|
1071
|
-
let repo = self.open_repo()?;
|
1072
|
-
let remote = repo.find_remote("origin").map_err(|_| {
|
1073
|
-
GitServiceError::InvalidRepository("No 'origin' remote found".to_string())
|
1074
|
-
})?;
|
1075
|
-
|
1076
|
-
let url = remote.url().ok_or_else(|| {
|
1077
|
-
GitServiceError::InvalidRepository("Remote origin has no URL".to_string())
|
1078
|
-
})?;
|
1079
|
-
|
1080
|
-
// Parse GitHub URL (supports both HTTPS and SSH formats)
|
1081
|
-
let github_regex = regex::Regex::new(r"github\.com[:/]([^/]+)/(.+?)(?:\.git)?/?$")
|
1082
|
-
.map_err(|e| GitServiceError::InvalidRepository(format!("Regex error: {}", e)))?;
|
1083
|
-
|
1084
|
-
if let Some(captures) = github_regex.captures(url) {
|
1085
|
-
let owner = captures.get(1).unwrap().as_str().to_string();
|
1086
|
-
let repo_name = captures.get(2).unwrap().as_str().to_string();
|
1087
|
-
Ok((owner, repo_name))
|
1088
|
-
} else {
|
1089
|
-
Err(GitServiceError::InvalidRepository(format!(
|
1090
|
-
"Not a GitHub repository: {}",
|
1091
|
-
url
|
1092
|
-
)))
|
1093
|
-
}
|
1094
|
-
}
|
1095
|
-
|
1096
|
-
/// Push the branch to GitHub remote
|
1097
|
-
pub fn push_to_github(
|
1098
|
-
&self,
|
1099
|
-
worktree_path: &Path,
|
1100
|
-
branch_name: &str,
|
1101
|
-
github_token: &str,
|
1102
|
-
) -> Result<(), GitServiceError> {
|
1103
|
-
let repo = Repository::open(worktree_path)?;
|
1104
|
-
|
1105
|
-
// Get the remote
|
1106
|
-
let remote = repo.find_remote("origin")?;
|
1107
|
-
let remote_url = remote.url().ok_or_else(|| {
|
1108
|
-
GitServiceError::InvalidRepository("Remote origin has no URL".to_string())
|
1109
|
-
})?;
|
1110
|
-
|
1111
|
-
// Convert SSH URL to HTTPS URL if necessary
|
1112
|
-
let https_url = if remote_url.starts_with("git@github.com:") {
|
1113
|
-
// Convert git@github.com:owner/repo.git to https://github.com/owner/repo.git
|
1114
|
-
remote_url.replace("git@github.com:", "https://github.com/")
|
1115
|
-
} else if remote_url.starts_with("ssh://git@github.com/") {
|
1116
|
-
// Convert ssh://git@github.com/owner/repo.git to https://github.com/owner/repo.git
|
1117
|
-
remote_url.replace("ssh://git@github.com/", "https://github.com/")
|
1118
|
-
} else {
|
1119
|
-
remote_url.to_string()
|
1120
|
-
};
|
1121
|
-
|
1122
|
-
// Create a temporary remote with HTTPS URL for pushing
|
1123
|
-
let temp_remote_name = "temp_https_origin";
|
1124
|
-
|
1125
|
-
// Remove any existing temp remote
|
1126
|
-
let _ = repo.remote_delete(temp_remote_name);
|
1127
|
-
|
1128
|
-
// Create temporary HTTPS remote
|
1129
|
-
let mut temp_remote = repo.remote(temp_remote_name, &https_url)?;
|
1130
|
-
|
1131
|
-
// Create refspec for pushing the branch
|
1132
|
-
let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name);
|
1133
|
-
|
1134
|
-
// Set up authentication callback using the GitHub token
|
1135
|
-
let mut callbacks = git2::RemoteCallbacks::new();
|
1136
|
-
callbacks.credentials(|_url, username_from_url, _allowed_types| {
|
1137
|
-
git2::Cred::userpass_plaintext(username_from_url.unwrap_or("git"), github_token)
|
1138
|
-
});
|
1139
|
-
|
1140
|
-
// Configure push options
|
1141
|
-
let mut push_options = git2::PushOptions::new();
|
1142
|
-
push_options.remote_callbacks(callbacks);
|
1143
|
-
|
1144
|
-
// Push the branch
|
1145
|
-
let push_result = temp_remote.push(&[&refspec], Some(&mut push_options));
|
1146
|
-
|
1147
|
-
// Clean up the temporary remote
|
1148
|
-
let _ = repo.remote_delete(temp_remote_name);
|
1149
|
-
|
1150
|
-
// Check push result
|
1151
|
-
push_result?;
|
1152
|
-
|
1153
|
-
info!("Pushed branch {} to GitHub using HTTPS", branch_name);
|
1154
|
-
Ok(())
|
1155
|
-
}
|
1156
|
-
|
1157
|
-
/// Fetch from remote repository, with SSH authentication callbacks
|
1158
|
-
fn fetch_from_remote(&self, repo: &Repository) -> Result<(), GitServiceError> {
|
1159
|
-
// Find the “origin” remote
|
1160
|
-
let mut remote = repo.find_remote("origin").map_err(|_| {
|
1161
|
-
GitServiceError::Git(git2::Error::from_str("Remote 'origin' not found"))
|
1162
|
-
})?;
|
1163
|
-
|
1164
|
-
// Prepare callbacks for authentication
|
1165
|
-
let mut callbacks = RemoteCallbacks::new();
|
1166
|
-
callbacks.credentials(|_url, username_from_url, _| {
|
1167
|
-
// Try SSH agent first
|
1168
|
-
if let Some(username) = username_from_url {
|
1169
|
-
if let Ok(cred) = Cred::ssh_key_from_agent(username) {
|
1170
|
-
return Ok(cred);
|
1171
|
-
}
|
1172
|
-
}
|
1173
|
-
// Fallback to key file (~/.ssh/id_rsa)
|
1174
|
-
let home = dirs::home_dir()
|
1175
|
-
.ok_or_else(|| git2::Error::from_str("Could not find home directory"))?;
|
1176
|
-
let key_path = home.join(".ssh").join("id_rsa");
|
1177
|
-
Cred::ssh_key(username_from_url.unwrap_or("git"), None, &key_path, None)
|
1178
|
-
});
|
1179
|
-
|
1180
|
-
// Set up fetch options with our callbacks
|
1181
|
-
let mut fetch_opts = FetchOptions::new();
|
1182
|
-
fetch_opts.remote_callbacks(callbacks);
|
1183
|
-
|
1184
|
-
// Actually fetch (no specific refspecs = fetch all configured)
|
1185
|
-
remote
|
1186
|
-
.fetch(&[] as &[&str], Some(&mut fetch_opts), None)
|
1187
|
-
.map_err(GitServiceError::Git)?;
|
1188
|
-
Ok(())
|
1189
|
-
}
|
1190
|
-
|
1191
|
-
/// Find the merge-base between two commits
|
1192
|
-
fn get_merge_base(
|
1193
|
-
repo: &Repository,
|
1194
|
-
commit1: git2::Oid,
|
1195
|
-
commit2: git2::Oid,
|
1196
|
-
) -> Result<git2::Oid, GitServiceError> {
|
1197
|
-
repo.merge_base(commit1, commit2)
|
1198
|
-
.map_err(GitServiceError::Git)
|
1199
|
-
}
|
1200
|
-
|
1201
|
-
/// Find commits that are unique to the task branch (not in either base branch)
|
1202
|
-
fn find_unique_commits(
|
1203
|
-
repo: &Repository,
|
1204
|
-
task_branch_commit: git2::Oid,
|
1205
|
-
old_base_commit: git2::Oid,
|
1206
|
-
new_base_commit: git2::Oid,
|
1207
|
-
) -> Result<Vec<git2::Oid>, GitServiceError> {
|
1208
|
-
// Find merge-base between task branch and old base branch
|
1209
|
-
let task_old_base_merge_base =
|
1210
|
-
Self::get_merge_base(repo, task_branch_commit, old_base_commit)?;
|
1211
|
-
|
1212
|
-
// Find merge-base between old base and new base
|
1213
|
-
let old_new_base_merge_base = Self::get_merge_base(repo, old_base_commit, new_base_commit)?;
|
1214
|
-
|
1215
|
-
// Get all commits from task branch back to the merge-base with old base
|
1216
|
-
let mut walker = repo.revwalk()?;
|
1217
|
-
walker.push(task_branch_commit)?;
|
1218
|
-
walker.hide(task_old_base_merge_base)?;
|
1219
|
-
|
1220
|
-
let mut task_commits = Vec::new();
|
1221
|
-
for commit_id in walker {
|
1222
|
-
let commit_id = commit_id?;
|
1223
|
-
|
1224
|
-
// Check if this commit is not in the old base branch lineage
|
1225
|
-
// (i.e., it's not between old_new_base_merge_base and old_base_commit)
|
1226
|
-
let is_in_old_base = repo
|
1227
|
-
.graph_descendant_of(commit_id, old_new_base_merge_base)
|
1228
|
-
.unwrap_or(false)
|
1229
|
-
&& repo
|
1230
|
-
.graph_descendant_of(old_base_commit, commit_id)
|
1231
|
-
.unwrap_or(false);
|
1232
|
-
|
1233
|
-
if !is_in_old_base {
|
1234
|
-
task_commits.push(commit_id);
|
1235
|
-
}
|
1236
|
-
}
|
1237
|
-
|
1238
|
-
// Reverse to get chronological order for cherry-picking
|
1239
|
-
task_commits.reverse();
|
1240
|
-
Ok(task_commits)
|
1241
|
-
}
|
1242
|
-
|
1243
|
-
/// Cherry-pick specific commits onto a new base
|
1244
|
-
fn cherry_pick_commits(
|
1245
|
-
repo: &Repository,
|
1246
|
-
commits: &[git2::Oid],
|
1247
|
-
signature: &git2::Signature,
|
1248
|
-
) -> Result<(), GitServiceError> {
|
1249
|
-
for &commit_id in commits {
|
1250
|
-
let commit = repo.find_commit(commit_id)?;
|
1251
|
-
|
1252
|
-
// Cherry-pick the commit
|
1253
|
-
let mut cherrypick_opts = CherrypickOptions::new();
|
1254
|
-
repo.cherrypick(&commit, Some(&mut cherrypick_opts))?;
|
1255
|
-
|
1256
|
-
// Check for conflicts
|
1257
|
-
let mut index = repo.index()?;
|
1258
|
-
if index.has_conflicts() {
|
1259
|
-
return Err(GitServiceError::MergeConflicts(format!(
|
1260
|
-
"Cherry-pick failed due to conflicts on commit {}",
|
1261
|
-
commit_id
|
1262
|
-
)));
|
1263
|
-
}
|
1264
|
-
|
1265
|
-
// Commit the cherry-pick
|
1266
|
-
let tree_id = index.write_tree()?;
|
1267
|
-
let tree = repo.find_tree(tree_id)?;
|
1268
|
-
let head_commit = repo.head()?.peel_to_commit()?;
|
1269
|
-
|
1270
|
-
repo.commit(
|
1271
|
-
Some("HEAD"),
|
1272
|
-
signature,
|
1273
|
-
signature,
|
1274
|
-
commit.message().unwrap_or("Cherry-picked commit"),
|
1275
|
-
&tree,
|
1276
|
-
&[&head_commit],
|
1277
|
-
)?;
|
1278
|
-
}
|
1279
|
-
|
1280
|
-
Ok(())
|
1281
|
-
}
|
1282
|
-
}
|
1283
|
-
|
1284
|
-
#[cfg(test)]
|
1285
|
-
mod tests {
|
1286
|
-
use tempfile::TempDir;
|
1287
|
-
|
1288
|
-
use super::*;
|
1289
|
-
|
1290
|
-
fn create_test_repo() -> (TempDir, Repository) {
|
1291
|
-
let temp_dir = TempDir::new().unwrap();
|
1292
|
-
let repo = Repository::init(temp_dir.path()).unwrap();
|
1293
|
-
|
1294
|
-
// Configure the repository
|
1295
|
-
let mut config = repo.config().unwrap();
|
1296
|
-
config.set_str("user.name", "Test User").unwrap();
|
1297
|
-
config.set_str("user.email", "test@example.com").unwrap();
|
1298
|
-
|
1299
|
-
(temp_dir, repo)
|
1300
|
-
}
|
1301
|
-
|
1302
|
-
#[test]
|
1303
|
-
fn test_git_service_creation() {
|
1304
|
-
let (temp_dir, _repo) = create_test_repo();
|
1305
|
-
let _git_service = GitService::new(temp_dir.path()).unwrap();
|
1306
|
-
}
|
1307
|
-
|
1308
|
-
#[test]
|
1309
|
-
fn test_invalid_repository_path() {
|
1310
|
-
let result = GitService::new("/nonexistent/path");
|
1311
|
-
assert!(result.is_err());
|
1312
|
-
}
|
1313
|
-
|
1314
|
-
#[test]
|
1315
|
-
fn test_default_branch_name() {
|
1316
|
-
let (temp_dir, _repo) = create_test_repo();
|
1317
|
-
let git_service = GitService::new(temp_dir.path()).unwrap();
|
1318
|
-
let branch_name = git_service.get_default_branch_name().unwrap();
|
1319
|
-
assert_eq!(branch_name, "main");
|
1320
|
-
}
|
1321
|
-
}
|