byterover-cli 2.6.0 → 3.0.1
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/.env.production +1 -0
- package/README.md +240 -14
- package/dist/agent/core/domain/knowledge/conflict-detector.d.ts +38 -0
- package/dist/agent/core/domain/knowledge/conflict-detector.js +71 -0
- package/dist/agent/core/domain/knowledge/conflict-resolver.d.ts +17 -0
- package/dist/agent/core/domain/knowledge/conflict-resolver.js +118 -0
- package/dist/agent/core/domain/knowledge/utils.d.ts +4 -0
- package/dist/agent/core/domain/knowledge/utils.js +6 -0
- package/dist/agent/core/interfaces/i-curate-service.d.ts +6 -0
- package/dist/agent/infra/tools/implementations/curate-tool.d.ts +67 -34
- package/dist/agent/infra/tools/implementations/curate-tool.js +294 -47
- package/dist/agent/resources/prompts/system-prompt.yml +15 -8
- package/dist/agent/resources/tools/code_exec.txt +3 -0
- package/dist/agent/resources/tools/curate.txt +12 -3
- package/dist/oclif/commands/connectors/install.d.ts +2 -1
- package/dist/oclif/commands/connectors/install.js +38 -3
- package/dist/oclif/commands/curate/index.d.ts +18 -0
- package/dist/oclif/commands/curate/index.js +78 -1
- package/dist/oclif/commands/init.d.ts +12 -0
- package/dist/oclif/commands/init.js +75 -0
- package/dist/oclif/commands/locations.js +1 -1
- package/dist/oclif/commands/providers/connect.d.ts +31 -1
- package/dist/oclif/commands/providers/connect.js +307 -27
- package/dist/oclif/commands/pull.d.ts +1 -0
- package/dist/oclif/commands/pull.js +7 -0
- package/dist/oclif/commands/push.d.ts +1 -0
- package/dist/oclif/commands/push.js +8 -0
- package/dist/oclif/commands/review/approve.d.ts +17 -0
- package/dist/oclif/commands/review/approve.js +37 -0
- package/dist/oclif/commands/review/base-review-decision.d.ts +18 -0
- package/dist/oclif/commands/review/base-review-decision.js +71 -0
- package/dist/oclif/commands/review/pending.d.ts +13 -0
- package/dist/oclif/commands/review/pending.js +94 -0
- package/dist/oclif/commands/review/reject.d.ts +17 -0
- package/dist/oclif/commands/review/reject.js +38 -0
- package/dist/oclif/commands/space/list.d.ts +2 -2
- package/dist/oclif/commands/space/list.js +13 -35
- package/dist/oclif/commands/space/switch.d.ts +2 -7
- package/dist/oclif/commands/space/switch.js +13 -56
- package/dist/oclif/commands/status.d.ts +1 -0
- package/dist/oclif/commands/status.js +11 -1
- package/dist/oclif/commands/vc/add.d.ts +7 -0
- package/dist/oclif/commands/vc/add.js +29 -0
- package/dist/oclif/commands/vc/branch.d.ts +15 -0
- package/dist/oclif/commands/vc/branch.js +70 -0
- package/dist/oclif/commands/vc/checkout.d.ts +14 -0
- package/dist/oclif/commands/vc/checkout.js +47 -0
- package/dist/oclif/commands/vc/clone.d.ts +9 -0
- package/dist/oclif/commands/vc/clone.js +61 -0
- package/dist/oclif/commands/vc/commit.d.ts +10 -0
- package/dist/oclif/commands/vc/commit.js +32 -0
- package/dist/oclif/commands/vc/config.d.ts +10 -0
- package/dist/oclif/commands/vc/config.js +30 -0
- package/dist/oclif/commands/vc/fetch.d.ts +10 -0
- package/dist/oclif/commands/vc/fetch.js +42 -0
- package/dist/oclif/commands/vc/index.d.ts +6 -0
- package/dist/oclif/commands/vc/index.js +8 -0
- package/dist/oclif/commands/vc/init.d.ts +6 -0
- package/dist/oclif/commands/vc/init.js +25 -0
- package/dist/oclif/commands/vc/log.d.ts +13 -0
- package/dist/oclif/commands/vc/log.js +48 -0
- package/dist/oclif/commands/vc/merge.d.ts +19 -0
- package/dist/oclif/commands/vc/merge.js +130 -0
- package/dist/oclif/commands/vc/pull.d.ts +13 -0
- package/dist/oclif/commands/vc/pull.js +60 -0
- package/dist/oclif/commands/vc/push.d.ts +13 -0
- package/dist/oclif/commands/vc/push.js +60 -0
- package/dist/oclif/commands/vc/remote/add.d.ts +10 -0
- package/dist/oclif/commands/vc/remote/add.js +30 -0
- package/dist/oclif/commands/vc/remote/index.d.ts +6 -0
- package/dist/oclif/commands/vc/remote/index.js +16 -0
- package/dist/oclif/commands/vc/remote/set-url.d.ts +10 -0
- package/dist/oclif/commands/vc/remote/set-url.js +30 -0
- package/dist/oclif/commands/vc/reset.d.ts +13 -0
- package/dist/oclif/commands/vc/reset.js +62 -0
- package/dist/oclif/commands/vc/status.d.ts +8 -0
- package/dist/oclif/commands/vc/status.js +106 -0
- package/dist/oclif/hooks/init/validate-brv-config.d.ts +26 -0
- package/dist/oclif/hooks/init/validate-brv-config.js +62 -0
- package/dist/oclif/lib/daemon-client.d.ts +2 -0
- package/dist/oclif/lib/daemon-client.js +36 -10
- package/dist/oclif/lib/prompt-utils.d.ts +43 -0
- package/dist/oclif/lib/prompt-utils.js +84 -0
- package/dist/oclif/lib/spinner.d.ts +8 -0
- package/dist/oclif/lib/spinner.js +23 -0
- package/dist/oclif/lib/task-client.d.ts +5 -0
- package/dist/oclif/lib/task-client.js +15 -2
- package/dist/server/config/environment.d.ts +2 -0
- package/dist/server/config/environment.js +2 -0
- package/dist/server/constants.d.ts +3 -0
- package/dist/server/constants.js +9 -0
- package/dist/server/core/domain/entities/auth-token.d.ts +2 -0
- package/dist/server/core/domain/entities/auth-token.js +7 -1
- package/dist/server/core/domain/entities/curate-log-entry.d.ts +11 -0
- package/dist/server/core/domain/entities/space.d.ts +4 -0
- package/dist/server/core/domain/entities/space.js +8 -0
- package/dist/server/core/domain/entities/team.d.ts +2 -0
- package/dist/server/core/domain/entities/team.js +4 -0
- package/dist/server/core/domain/errors/git-error.d.ts +6 -0
- package/dist/server/core/domain/errors/git-error.js +12 -0
- package/dist/server/core/domain/errors/task-error.d.ts +4 -0
- package/dist/server/core/domain/errors/task-error.js +8 -0
- package/dist/server/core/domain/errors/vc-error.d.ts +5 -0
- package/dist/server/core/domain/errors/vc-error.js +8 -0
- package/dist/server/core/domain/knowledge/markdown-writer.d.ts +4 -1
- package/dist/server/core/domain/knowledge/markdown-writer.js +37 -7
- package/dist/server/core/domain/transport/schemas.d.ts +6 -6
- package/dist/server/core/interfaces/context-tree/i-context-tree-service.d.ts +11 -0
- package/dist/server/core/interfaces/process/i-task-lifecycle-hook.d.ts +6 -0
- package/dist/server/core/interfaces/services/i-git-service.d.ts +234 -0
- package/dist/server/core/interfaces/services/i-git-service.js +1 -0
- package/dist/server/core/interfaces/storage/i-curate-log-store.d.ts +5 -0
- package/dist/server/core/interfaces/storage/i-review-backup-store.d.ts +19 -0
- package/dist/server/core/interfaces/storage/i-review-backup-store.js +1 -0
- package/dist/server/core/interfaces/vc/i-vc-git-config-store.d.ts +8 -0
- package/dist/server/core/interfaces/vc/i-vc-git-config-store.js +1 -0
- package/dist/server/infra/config/auto-init.d.ts +0 -2
- package/dist/server/infra/config/auto-init.js +0 -1
- package/dist/server/infra/context-tree/file-context-tree-service.d.ts +2 -0
- package/dist/server/infra/context-tree/file-context-tree-service.js +13 -0
- package/dist/server/infra/daemon/brv-server.js +23 -3
- package/dist/server/infra/git/cogit-url.d.ts +17 -0
- package/dist/server/infra/git/cogit-url.js +39 -0
- package/dist/server/infra/git/git-http-wrapper.d.ts +20 -0
- package/dist/server/infra/git/git-http-wrapper.js +334 -0
- package/dist/server/infra/git/isomorphic-git-service.d.ts +78 -0
- package/dist/server/infra/git/isomorphic-git-service.js +983 -0
- package/dist/server/infra/http/review-api-handler.d.ts +13 -0
- package/dist/server/infra/http/review-api-handler.js +286 -0
- package/dist/server/infra/http/review-ui.d.ts +7 -0
- package/dist/server/infra/http/review-ui.js +606 -0
- package/dist/server/infra/mcp/tools/brv-curate-tool.d.ts +2 -2
- package/dist/server/infra/process/curate-log-handler.d.ts +18 -2
- package/dist/server/infra/process/curate-log-handler.js +50 -13
- package/dist/server/infra/process/feature-handlers.js +41 -1
- package/dist/server/infra/process/task-router.js +16 -0
- package/dist/server/infra/space/http-space-service.js +2 -0
- package/dist/server/infra/storage/file-curate-log-store.d.ts +10 -0
- package/dist/server/infra/storage/file-curate-log-store.js +35 -0
- package/dist/server/infra/storage/file-review-backup-store.d.ts +29 -0
- package/dist/server/infra/storage/file-review-backup-store.js +121 -0
- package/dist/server/infra/transport/handlers/auth-handler.js +9 -5
- package/dist/server/infra/transport/handlers/handler-types.d.ts +9 -0
- package/dist/server/infra/transport/handlers/handler-types.js +11 -0
- package/dist/server/infra/transport/handlers/index.d.ts +4 -0
- package/dist/server/infra/transport/handlers/index.js +2 -0
- package/dist/server/infra/transport/handlers/init-handler.d.ts +1 -0
- package/dist/server/infra/transport/handlers/init-handler.js +13 -1
- package/dist/server/infra/transport/handlers/pull-handler.d.ts +3 -0
- package/dist/server/infra/transport/handlers/pull-handler.js +5 -1
- package/dist/server/infra/transport/handlers/push-handler.d.ts +20 -0
- package/dist/server/infra/transport/handlers/push-handler.js +116 -14
- package/dist/server/infra/transport/handlers/reset-handler.d.ts +11 -0
- package/dist/server/infra/transport/handlers/reset-handler.js +37 -1
- package/dist/server/infra/transport/handlers/review-handler.d.ts +35 -0
- package/dist/server/infra/transport/handlers/review-handler.js +162 -0
- package/dist/server/infra/transport/handlers/space-handler.d.ts +3 -0
- package/dist/server/infra/transport/handlers/space-handler.js +4 -1
- package/dist/server/infra/transport/handlers/status-handler.d.ts +5 -0
- package/dist/server/infra/transport/handlers/status-handler.js +51 -16
- package/dist/server/infra/transport/handlers/vc-handler.d.ts +100 -0
- package/dist/server/infra/transport/handlers/vc-handler.js +1050 -0
- package/dist/server/infra/transport/socket-io-transport-server.d.ts +7 -0
- package/dist/server/infra/transport/socket-io-transport-server.js +12 -1
- package/dist/server/infra/transport/transport-connector.d.ts +1 -1
- package/dist/server/infra/transport/transport-connector.js +2 -1
- package/dist/server/infra/vc/file-vc-git-config-store.d.ts +11 -0
- package/dist/server/infra/vc/file-vc-git-config-store.js +43 -0
- package/dist/server/templates/skill/SKILL.md +167 -33
- package/dist/server/utils/curate-result-parser.d.ts +64 -0
- package/dist/server/utils/curate-result-parser.js +8 -0
- package/dist/server/utils/gitignore.d.ts +9 -0
- package/dist/server/utils/gitignore.js +47 -0
- package/dist/shared/transport/events/index.d.ts +6 -0
- package/dist/shared/transport/events/index.js +3 -0
- package/dist/shared/transport/events/init-events.d.ts +8 -0
- package/dist/shared/transport/events/init-events.js +1 -0
- package/dist/shared/transport/events/push-events.d.ts +6 -0
- package/dist/shared/transport/events/review-events.d.ts +41 -0
- package/dist/shared/transport/events/review-events.js +5 -0
- package/dist/shared/transport/events/vc-events.d.ts +257 -0
- package/dist/shared/transport/events/vc-events.js +67 -0
- package/dist/shared/transport/types/dto.d.ts +6 -1
- package/dist/tui/app/pages/init-project-page.d.ts +9 -0
- package/dist/tui/app/pages/init-project-page.js +54 -0
- package/dist/tui/app/pages/protected-routes.js +14 -6
- package/dist/tui/components/index.d.ts +0 -2
- package/dist/tui/components/index.js +0 -1
- package/dist/tui/features/activity/hooks/use-activity-logs.js +7 -1
- package/dist/tui/features/commands/definitions/index.js +3 -0
- package/dist/tui/features/commands/definitions/space-list.js +9 -18
- package/dist/tui/features/commands/definitions/space-switch.js +10 -6
- package/dist/tui/features/commands/definitions/vc-add.d.ts +2 -0
- package/dist/tui/features/commands/definitions/vc-add.js +15 -0
- package/dist/tui/features/commands/definitions/vc-branch.d.ts +2 -0
- package/dist/tui/features/commands/definitions/vc-branch.js +33 -0
- package/dist/tui/features/commands/definitions/vc-checkout.d.ts +2 -0
- package/dist/tui/features/commands/definitions/vc-checkout.js +32 -0
- package/dist/tui/features/commands/definitions/vc-clone.d.ts +2 -0
- package/dist/tui/features/commands/definitions/vc-clone.js +18 -0
- package/dist/tui/features/commands/definitions/vc-commit.d.ts +2 -0
- package/dist/tui/features/commands/definitions/vc-commit.js +32 -0
- package/dist/tui/features/commands/definitions/vc-config.d.ts +2 -0
- package/dist/tui/features/commands/definitions/vc-config.js +40 -0
- package/dist/tui/features/commands/definitions/vc-fetch.d.ts +2 -0
- package/dist/tui/features/commands/definitions/vc-fetch.js +37 -0
- package/dist/tui/features/commands/definitions/vc-init.d.ts +2 -0
- package/dist/tui/features/commands/definitions/vc-init.js +11 -0
- package/dist/tui/features/commands/definitions/vc-log.d.ts +2 -0
- package/dist/tui/features/commands/definitions/vc-log.js +25 -0
- package/dist/tui/features/commands/definitions/vc-merge.d.ts +2 -0
- package/dist/tui/features/commands/definitions/vc-merge.js +48 -0
- package/dist/tui/features/commands/definitions/vc-pull.d.ts +2 -0
- package/dist/tui/features/commands/definitions/vc-pull.js +42 -0
- package/dist/tui/features/commands/definitions/vc-push.d.ts +2 -0
- package/dist/tui/features/commands/definitions/vc-push.js +38 -0
- package/dist/tui/features/commands/definitions/vc-remote.d.ts +2 -0
- package/dist/tui/features/commands/definitions/vc-remote.js +57 -0
- package/dist/tui/features/commands/definitions/vc-reset.d.ts +2 -0
- package/dist/tui/features/commands/definitions/vc-reset.js +35 -0
- package/dist/tui/features/commands/definitions/vc-status.d.ts +2 -0
- package/dist/tui/features/commands/definitions/vc-status.js +11 -0
- package/dist/tui/features/commands/definitions/vc.d.ts +2 -0
- package/dist/tui/features/commands/definitions/vc.js +36 -0
- package/dist/tui/features/commands/hooks/use-slash-command-processor.js +5 -5
- package/dist/tui/features/log/api/execute-log.d.ts +8 -0
- package/dist/tui/features/log/api/execute-log.js +13 -0
- package/dist/tui/features/log/components/log-flow.d.ts +14 -0
- package/dist/tui/features/log/components/log-flow.js +29 -0
- package/dist/tui/features/log/utils/format-log.d.ts +3 -0
- package/dist/tui/features/log/utils/format-log.js +42 -0
- package/dist/tui/features/onboarding/hooks/use-app-view-mode.d.ts +9 -5
- package/dist/tui/features/onboarding/hooks/use-app-view-mode.js +12 -5
- package/dist/tui/features/push/components/push-flow.js +9 -2
- package/dist/tui/features/reset/components/reset-flow.js +2 -1
- package/dist/tui/features/status/components/status-view.js +2 -1
- package/dist/tui/features/status/utils/format-status.js +9 -0
- package/dist/tui/features/tasks/hooks/use-task-subscriptions.js +11 -0
- package/dist/tui/features/tasks/stores/tasks-store.d.ts +10 -0
- package/dist/tui/features/tasks/stores/tasks-store.js +16 -0
- package/dist/tui/features/vc/add/api/execute-vc-add.d.ts +8 -0
- package/dist/tui/features/vc/add/api/execute-vc-add.js +13 -0
- package/dist/tui/features/vc/add/components/vc-add-flow.d.ts +7 -0
- package/dist/tui/features/vc/add/components/vc-add-flow.js +35 -0
- package/dist/tui/features/vc/branch/api/execute-vc-branch.d.ts +8 -0
- package/dist/tui/features/vc/branch/api/execute-vc-branch.js +13 -0
- package/dist/tui/features/vc/branch/components/vc-branch-flow.d.ts +8 -0
- package/dist/tui/features/vc/branch/components/vc-branch-flow.js +53 -0
- package/dist/tui/features/vc/branch/utils/format-branch.d.ts +4 -0
- package/dist/tui/features/vc/branch/utils/format-branch.js +12 -0
- package/dist/tui/features/vc/checkout/api/execute-vc-checkout.d.ts +8 -0
- package/dist/tui/features/vc/checkout/api/execute-vc-checkout.js +13 -0
- package/dist/tui/features/vc/checkout/components/vc-checkout-flow.d.ts +8 -0
- package/dist/tui/features/vc/checkout/components/vc-checkout-flow.js +33 -0
- package/dist/tui/features/vc/clone/api/execute-vc-clone.d.ts +8 -0
- package/dist/tui/features/vc/clone/api/execute-vc-clone.js +13 -0
- package/dist/tui/features/vc/clone/components/vc-clone-flow.d.ts +7 -0
- package/dist/tui/features/vc/clone/components/vc-clone-flow.js +79 -0
- package/dist/tui/features/vc/commit/api/execute-vc-commit.d.ts +8 -0
- package/dist/tui/features/vc/commit/api/execute-vc-commit.js +13 -0
- package/dist/tui/features/vc/commit/components/vc-commit-flow.d.ts +7 -0
- package/dist/tui/features/vc/commit/components/vc-commit-flow.js +29 -0
- package/dist/tui/features/vc/config/api/execute-vc-config.d.ts +8 -0
- package/dist/tui/features/vc/config/api/execute-vc-config.js +13 -0
- package/dist/tui/features/vc/config/components/vc-config-flow.d.ts +9 -0
- package/dist/tui/features/vc/config/components/vc-config-flow.js +30 -0
- package/dist/tui/features/vc/fetch/api/execute-vc-fetch.d.ts +8 -0
- package/dist/tui/features/vc/fetch/api/execute-vc-fetch.js +13 -0
- package/dist/tui/features/vc/fetch/components/vc-fetch-flow.d.ts +8 -0
- package/dist/tui/features/vc/fetch/components/vc-fetch-flow.js +75 -0
- package/dist/tui/features/vc/init/api/execute-vc-init.d.ts +8 -0
- package/dist/tui/features/vc/init/api/execute-vc-init.js +13 -0
- package/dist/tui/features/vc/init/components/vc-init-flow.d.ts +10 -0
- package/dist/tui/features/vc/init/components/vc-init-flow.js +37 -0
- package/dist/tui/features/vc/merge/api/execute-vc-merge.d.ts +8 -0
- package/dist/tui/features/vc/merge/api/execute-vc-merge.js +13 -0
- package/dist/tui/features/vc/merge/components/vc-merge-flow.d.ts +11 -0
- package/dist/tui/features/vc/merge/components/vc-merge-flow.js +72 -0
- package/dist/tui/features/vc/pull/api/execute-vc-pull.d.ts +8 -0
- package/dist/tui/features/vc/pull/api/execute-vc-pull.js +13 -0
- package/dist/tui/features/vc/pull/components/vc-pull-flow.d.ts +9 -0
- package/dist/tui/features/vc/pull/components/vc-pull-flow.js +83 -0
- package/dist/tui/features/vc/push/api/execute-vc-push.d.ts +8 -0
- package/dist/tui/features/vc/push/api/execute-vc-push.js +13 -0
- package/dist/tui/features/vc/push/components/vc-push-flow.d.ts +8 -0
- package/dist/tui/features/vc/push/components/vc-push-flow.js +83 -0
- package/dist/tui/features/vc/remote/api/execute-vc-remote.d.ts +8 -0
- package/dist/tui/features/vc/remote/api/execute-vc-remote.js +13 -0
- package/dist/tui/features/vc/remote/components/vc-remote-flow.d.ts +9 -0
- package/dist/tui/features/vc/remote/components/vc-remote-flow.js +42 -0
- package/dist/tui/features/vc/reset/api/execute-vc-reset.d.ts +8 -0
- package/dist/tui/features/vc/reset/api/execute-vc-reset.js +13 -0
- package/dist/tui/features/vc/reset/components/vc-reset-flow.d.ts +10 -0
- package/dist/tui/features/vc/reset/components/vc-reset-flow.js +63 -0
- package/dist/tui/features/vc/status/api/execute-vc-status.d.ts +8 -0
- package/dist/tui/features/vc/status/api/execute-vc-status.js +13 -0
- package/dist/tui/features/vc/status/components/vc-status-flow.d.ts +10 -0
- package/dist/tui/features/vc/status/components/vc-status-flow.js +133 -0
- package/dist/tui/lib/environment.d.ts +8 -0
- package/dist/tui/lib/environment.js +8 -0
- package/dist/tui/utils/error-messages.d.ts +5 -1
- package/dist/tui/utils/error-messages.js +32 -3
- package/oclif.manifest.json +1018 -98
- package/package.json +9 -3
- package/dist/oclif/hooks/prerun/validate-brv-config-version.d.ts +0 -33
- package/dist/oclif/hooks/prerun/validate-brv-config-version.js +0 -86
- package/dist/tui/components/init.d.ts +0 -33
- package/dist/tui/components/init.js +0 -234
- package/dist/tui/features/space/api/get-spaces.d.ts +0 -16
- package/dist/tui/features/space/api/get-spaces.js +0 -17
- package/dist/tui/features/space/api/switch-space.d.ts +0 -11
- package/dist/tui/features/space/api/switch-space.js +0 -24
- package/dist/tui/features/space/components/space-list-view.d.ts +0 -12
- package/dist/tui/features/space/components/space-list-view.js +0 -56
- package/dist/tui/features/space/components/space-switch-flow.d.ts +0 -13
- package/dist/tui/features/space/components/space-switch-flow.js +0 -97
|
@@ -0,0 +1,983 @@
|
|
|
1
|
+
import * as git from 'isomorphic-git';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { GitAuthError, GitError } from '../../core/domain/errors/git-error.js';
|
|
5
|
+
import { gitHttpWrapper as http } from './git-http-wrapper.js';
|
|
6
|
+
/** Max commit depth for ahead/behind calculation. Counts beyond this are truncated. */
|
|
7
|
+
const MAX_AHEAD_BEHIND_DEPTH = 500;
|
|
8
|
+
export class IsomorphicGitService {
|
|
9
|
+
authStateStore;
|
|
10
|
+
constructor(authStateStore) {
|
|
11
|
+
this.authStateStore = authStateStore;
|
|
12
|
+
}
|
|
13
|
+
static isConflictError(error) {
|
|
14
|
+
return error.name === 'MergeConflictError' && 'data' in error;
|
|
15
|
+
}
|
|
16
|
+
static isMergeConflictData(data) {
|
|
17
|
+
if (typeof data !== 'object' || data === null)
|
|
18
|
+
return false;
|
|
19
|
+
return 'filepaths' in data && Array.isArray(data.filepaths);
|
|
20
|
+
}
|
|
21
|
+
async abortMerge(params) {
|
|
22
|
+
const dir = this.requireDirectory(params);
|
|
23
|
+
const mergeHeadPath = join(dir, '.git', 'MERGE_HEAD');
|
|
24
|
+
const mergeMsgPath = join(dir, '.git', 'MERGE_MSG');
|
|
25
|
+
// Identify files introduced by the merge source (in MERGE_HEAD tree but not in HEAD tree).
|
|
26
|
+
// These files appeared on disk during merge and must be removed on abort.
|
|
27
|
+
// We compare tree contents (not statusMatrix) because merge-introduced files and
|
|
28
|
+
// pre-existing untracked files both show as [0,2,0] in the status matrix.
|
|
29
|
+
// Get current branch BEFORE checkout to avoid detached HEAD
|
|
30
|
+
const branch = await this.getCurrentBranch(params);
|
|
31
|
+
let mergeIntroducedFiles = [];
|
|
32
|
+
const mergeHeadOid = await fs.promises
|
|
33
|
+
.readFile(mergeHeadPath, 'utf8')
|
|
34
|
+
.then((s) => s.trim())
|
|
35
|
+
.catch(() => null);
|
|
36
|
+
if (mergeHeadOid) {
|
|
37
|
+
const headFiles = new Set(await git.listFiles({ dir, fs, ref: branch ?? 'HEAD' }));
|
|
38
|
+
const mergeFiles = await git.listFiles({ dir, fs, ref: mergeHeadOid });
|
|
39
|
+
mergeIntroducedFiles = mergeFiles.filter((f) => !headFiles.has(f));
|
|
40
|
+
}
|
|
41
|
+
await git.checkout({ dir, force: true, fs, ref: branch ?? 'HEAD' });
|
|
42
|
+
// Clean up files that the merge brought in (exist in MERGE_HEAD but not in HEAD)
|
|
43
|
+
for (const filepath of mergeIntroducedFiles) {
|
|
44
|
+
// eslint-disable-next-line no-await-in-loop
|
|
45
|
+
await fs.promises.unlink(join(dir, filepath)).catch(() => { });
|
|
46
|
+
}
|
|
47
|
+
await fs.promises.unlink(mergeHeadPath).catch(() => { });
|
|
48
|
+
await fs.promises.unlink(mergeMsgPath).catch(() => { });
|
|
49
|
+
}
|
|
50
|
+
async add(params) {
|
|
51
|
+
const dir = this.requireDirectory(params);
|
|
52
|
+
// Identify deleted files (exist in HEAD + index but not on disk: [1,0,1])
|
|
53
|
+
// git.add() silently ignores missing files; git.remove() is required to stage deletions
|
|
54
|
+
const matrix = await git.statusMatrix({ dir, fs });
|
|
55
|
+
// Covers [1,0,1] (unstaged deletion) and [1,0,2] (post-merge-conflict absent file).
|
|
56
|
+
// stage !== 0 excludes [1,0,0] which is already staged as deletion — no git.remove() needed.
|
|
57
|
+
const deletedInIndex = new Set(matrix
|
|
58
|
+
.filter(([, head, workdir, stage]) => head === 1 && workdir === 0 && stage !== 0)
|
|
59
|
+
.map(([filepath]) => String(filepath)));
|
|
60
|
+
// Files not present on disk at all (workdir=0): covers both [1,0,1] (unstaged deletion)
|
|
61
|
+
// and [1,0,0] (already staged deletion). git.add() on these exact paths throws; skip toAdd.
|
|
62
|
+
const notOnDisk = new Set(matrix.filter((row) => row[2] === 0).map((row) => String(row[0])));
|
|
63
|
+
const toRemove = [];
|
|
64
|
+
const toAdd = [];
|
|
65
|
+
for (const rp of params.filePaths) {
|
|
66
|
+
const matchesDelete = (filepath) => {
|
|
67
|
+
if (rp === '.')
|
|
68
|
+
return true;
|
|
69
|
+
if (filepath === rp)
|
|
70
|
+
return true;
|
|
71
|
+
const prefix = rp.endsWith('/') ? rp : `${rp}/`;
|
|
72
|
+
return filepath.startsWith(prefix);
|
|
73
|
+
};
|
|
74
|
+
for (const deleted of deletedInIndex) {
|
|
75
|
+
if (matchesDelete(deleted))
|
|
76
|
+
toRemove.push(deleted);
|
|
77
|
+
}
|
|
78
|
+
// Don't call git.add() for exact paths not on disk — git.remove() handles [1,0,1] above,
|
|
79
|
+
// and [1,0,0] (already staged deletion) needs no further action.
|
|
80
|
+
// git.add('.') and directory patterns are fine (silently skip missing files).
|
|
81
|
+
if (!notOnDisk.has(rp)) {
|
|
82
|
+
toAdd.push(rp);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const results = await Promise.allSettled([
|
|
86
|
+
...toRemove.map((filepath) => git.remove({ dir, filepath, fs })),
|
|
87
|
+
...toAdd.map((filepath) => git.add({ dir, filepath, fs })),
|
|
88
|
+
]);
|
|
89
|
+
const allPaths = [...toRemove, ...toAdd];
|
|
90
|
+
const failed = results
|
|
91
|
+
.map((r, i) => ({ path: allPaths[i], result: r }))
|
|
92
|
+
.filter((x) => x.result.status === 'rejected');
|
|
93
|
+
if (failed.length > 0) {
|
|
94
|
+
const paths = failed.map((f) => f.path).join(', ');
|
|
95
|
+
throw new GitError(`Failed to stage: ${paths}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async addRemote(params) {
|
|
99
|
+
const dir = this.requireDirectory(params);
|
|
100
|
+
await git.addRemote({ dir, fs, remote: params.remote, url: params.url });
|
|
101
|
+
}
|
|
102
|
+
async checkout(params) {
|
|
103
|
+
const dir = this.requireDirectory(params);
|
|
104
|
+
// Snapshot tracked files in current branch BEFORE checkout.
|
|
105
|
+
// isomorphic-git's checkout only restores target files but does NOT remove
|
|
106
|
+
// files that are tracked in the source branch but absent in the target.
|
|
107
|
+
// Native git removes them, so we do it manually after checkout.
|
|
108
|
+
const sourceBranch = await this.getCurrentBranch(params);
|
|
109
|
+
const sourceFiles = sourceBranch ? new Set(await git.listFiles({ dir, fs, ref: sourceBranch })) : new Set();
|
|
110
|
+
// isomorphic-git's checkout detects unstaged conflicts (CheckoutConflictError)
|
|
111
|
+
// but silently overwrites staged changes — a data-loss bug. Guard staged
|
|
112
|
+
// conflicts here to match native git behavior.
|
|
113
|
+
if (!params.force && sourceBranch) {
|
|
114
|
+
await this.guardStagedConflicts(dir, sourceBranch, params.ref);
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
await git.checkout({ dir, force: params.force, fs, ref: params.ref });
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (error instanceof git.Errors.CheckoutConflictError) {
|
|
121
|
+
throw new GitError('Your local changes to the following files would be overwritten by checkout. ' +
|
|
122
|
+
'Commit your changes or stash them before you switch branches.');
|
|
123
|
+
}
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
// Remove files tracked in source but not in target (matches native git behavior).
|
|
127
|
+
// Untracked files are not in either set, so they are preserved.
|
|
128
|
+
if (sourceFiles.size > 0) {
|
|
129
|
+
const targetFiles = new Set(await git.listFiles({ dir, fs, ref: params.ref }));
|
|
130
|
+
for (const filepath of sourceFiles) {
|
|
131
|
+
if (!targetFiles.has(filepath)) {
|
|
132
|
+
// eslint-disable-next-line no-await-in-loop
|
|
133
|
+
await fs.promises.unlink(join(dir, filepath)).catch(() => { });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async clone(params) {
|
|
139
|
+
const dir = this.requireDirectory(params);
|
|
140
|
+
const token = this.requireToken();
|
|
141
|
+
await git.clone({
|
|
142
|
+
dir,
|
|
143
|
+
fs,
|
|
144
|
+
headers: this.buildBasicAuthHeaders(token.userId, token.sessionKey),
|
|
145
|
+
http,
|
|
146
|
+
onAuth: this.getOnAuth(),
|
|
147
|
+
onAuthFailure: this.getOnAuthFailure(),
|
|
148
|
+
onProgress: params.onProgress,
|
|
149
|
+
url: params.url,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
async commit(params) {
|
|
153
|
+
const dir = this.requireDirectory(params);
|
|
154
|
+
const author = params.author ?? this.getAuthor();
|
|
155
|
+
// If MERGE_HEAD exists, create a proper merge commit with two parents
|
|
156
|
+
const mergeHeadPath = join(dir, '.git', 'MERGE_HEAD');
|
|
157
|
+
const mergeMsgPath = join(dir, '.git', 'MERGE_MSG');
|
|
158
|
+
const mergeHeadContent = await fs.promises.readFile(mergeHeadPath, 'utf8').catch(() => null);
|
|
159
|
+
const mergeHead = mergeHeadContent?.trim() ?? null;
|
|
160
|
+
let parent;
|
|
161
|
+
if (mergeHead) {
|
|
162
|
+
const headSha = await git.resolveRef({ dir, fs, ref: 'HEAD' });
|
|
163
|
+
parent = [headSha, mergeHead];
|
|
164
|
+
}
|
|
165
|
+
let sha;
|
|
166
|
+
try {
|
|
167
|
+
sha = await git.commit({ author, dir, fs, message: params.message, ...(parent ? { parent } : {}) });
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
if (error instanceof git.Errors.UnmergedPathsError) {
|
|
171
|
+
const paths = error.data.filepaths.join(', ');
|
|
172
|
+
throw new GitError(`Unmerged files must be resolved before committing: ${paths}`);
|
|
173
|
+
}
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
const { commit: commitObj } = await git.readCommit({ dir, fs, oid: sha });
|
|
177
|
+
// Clean up MERGE_HEAD and MERGE_MSG (isomorphic-git does not remove them automatically)
|
|
178
|
+
await fs.promises.unlink(mergeHeadPath).catch(() => { });
|
|
179
|
+
await fs.promises.unlink(mergeMsgPath).catch(() => { });
|
|
180
|
+
return {
|
|
181
|
+
author,
|
|
182
|
+
message: params.message,
|
|
183
|
+
sha,
|
|
184
|
+
timestamp: new Date(commitObj.author.timestamp * 1000),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
async createBranch(params) {
|
|
188
|
+
const dir = this.requireDirectory(params);
|
|
189
|
+
await git.branch({ checkout: params.checkout, dir, fs, ref: params.branch });
|
|
190
|
+
}
|
|
191
|
+
async deleteBranch(params) {
|
|
192
|
+
const dir = this.requireDirectory(params);
|
|
193
|
+
await git.deleteBranch({ dir, fs, ref: params.branch });
|
|
194
|
+
}
|
|
195
|
+
async fetch(params) {
|
|
196
|
+
const dir = this.requireDirectory(params);
|
|
197
|
+
const token = this.requireToken();
|
|
198
|
+
await git.fetch({
|
|
199
|
+
dir,
|
|
200
|
+
fs,
|
|
201
|
+
headers: this.buildBasicAuthHeaders(token.userId, token.sessionKey),
|
|
202
|
+
http,
|
|
203
|
+
onAuth: this.getOnAuth(),
|
|
204
|
+
onAuthFailure: this.getOnAuthFailure(),
|
|
205
|
+
ref: params.ref,
|
|
206
|
+
remote: params.remote ?? 'origin',
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
async getAheadBehind(params) {
|
|
210
|
+
const dir = this.requireDirectory(params);
|
|
211
|
+
const localSha = await git.resolveRef({ dir, fs, ref: params.localRef }).catch(() => null);
|
|
212
|
+
const remoteSha = await git.resolveRef({ dir, fs, ref: params.remoteRef }).catch(() => null);
|
|
213
|
+
if (!localSha || !remoteSha)
|
|
214
|
+
return { ahead: 0, behind: 0 };
|
|
215
|
+
if (localSha === remoteSha)
|
|
216
|
+
return { ahead: 0, behind: 0 };
|
|
217
|
+
const [localLog, remoteLog] = await Promise.all([
|
|
218
|
+
git.log({ depth: MAX_AHEAD_BEHIND_DEPTH, dir, fs, ref: params.localRef }).catch(() => []),
|
|
219
|
+
git.log({ depth: MAX_AHEAD_BEHIND_DEPTH, dir, fs, ref: params.remoteRef }).catch(() => []),
|
|
220
|
+
]);
|
|
221
|
+
const localShas = new Set(localLog.map((c) => c.oid));
|
|
222
|
+
const remoteShas = new Set(remoteLog.map((c) => c.oid));
|
|
223
|
+
const ahead = localLog.filter((c) => !remoteShas.has(c.oid)).length;
|
|
224
|
+
const behind = remoteLog.filter((c) => !localShas.has(c.oid)).length;
|
|
225
|
+
return { ahead, behind };
|
|
226
|
+
}
|
|
227
|
+
async getConflicts(params) {
|
|
228
|
+
const dir = this.requireDirectory(params);
|
|
229
|
+
// Only report conflicts when a merge is actually in progress
|
|
230
|
+
const mergeInProgress = await fs.promises
|
|
231
|
+
.access(join(dir, '.git', 'MERGE_HEAD'))
|
|
232
|
+
.then(() => true)
|
|
233
|
+
.catch(() => false);
|
|
234
|
+
if (!mergeInProgress)
|
|
235
|
+
return [];
|
|
236
|
+
const matrix = await git.statusMatrix({ dir, fs });
|
|
237
|
+
const conflicts = [];
|
|
238
|
+
await Promise.all(matrix.map(async ([filepath, head, workdir, stage]) => {
|
|
239
|
+
const path = String(filepath);
|
|
240
|
+
// deleted_modified: file was in HEAD but gone from workdir
|
|
241
|
+
if (head === 1 && workdir === 0) {
|
|
242
|
+
conflicts.push({ path, type: 'deleted_modified' });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
// deleted_modified (isomorphic-git variant): file in HEAD, on disk unchanged,
|
|
246
|
+
// but index differs from both (stage=3). This happens when the other branch
|
|
247
|
+
// deletes a file that we modified — isomorphic-git keeps our version on disk.
|
|
248
|
+
if (head === 1 && workdir === 1 && stage === 3) {
|
|
249
|
+
conflicts.push({ path, type: 'deleted_modified' });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
// both_added or both_modified: look for conflict markers in file content
|
|
253
|
+
if (workdir === 2) {
|
|
254
|
+
try {
|
|
255
|
+
const content = await fs.promises.readFile(join(dir, path), 'utf8');
|
|
256
|
+
if (content.includes('<<<<<<<')) {
|
|
257
|
+
const type = head === 0 ? 'both_added' : 'both_modified';
|
|
258
|
+
conflicts.push({ path, type });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// skip binary or unreadable files
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}));
|
|
266
|
+
return conflicts.sort((a, b) => a.path.localeCompare(b.path));
|
|
267
|
+
}
|
|
268
|
+
async getCurrentBranch(params) {
|
|
269
|
+
const dir = this.requireDirectory(params);
|
|
270
|
+
const branch = await git.currentBranch({ dir, fs });
|
|
271
|
+
return branch ?? undefined;
|
|
272
|
+
}
|
|
273
|
+
async getFilesWithConflictMarkers(params) {
|
|
274
|
+
const dir = this.requireDirectory(params);
|
|
275
|
+
const matrix = await git.statusMatrix({ dir, fs });
|
|
276
|
+
const conflicted = [];
|
|
277
|
+
await Promise.all(matrix.map(async ([filepath, , workdir]) => {
|
|
278
|
+
const path = String(filepath);
|
|
279
|
+
// Only check files that exist in the working directory
|
|
280
|
+
if (workdir === 0)
|
|
281
|
+
return;
|
|
282
|
+
try {
|
|
283
|
+
const content = await fs.promises.readFile(join(dir, path), 'utf8');
|
|
284
|
+
if (content.includes('<<<<<<<') && content.includes('=======') && content.includes('>>>>>>>')) {
|
|
285
|
+
conflicted.push(path);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
// skip binary or unreadable files
|
|
290
|
+
}
|
|
291
|
+
}));
|
|
292
|
+
return conflicted.sort();
|
|
293
|
+
}
|
|
294
|
+
async getRemoteUrl(params) {
|
|
295
|
+
const dir = this.requireDirectory(params);
|
|
296
|
+
const result = await git.getConfig({ dir, fs, path: `remote.${params.remote}.url` });
|
|
297
|
+
return result === undefined || result === null ? undefined : String(result);
|
|
298
|
+
}
|
|
299
|
+
async getTrackingBranch(params) {
|
|
300
|
+
const dir = this.requireDirectory(params);
|
|
301
|
+
const remote = await git.getConfig({ dir, fs, path: `branch.${params.branch}.remote` });
|
|
302
|
+
if (remote === undefined || remote === null)
|
|
303
|
+
return undefined;
|
|
304
|
+
const merge = await git.getConfig({ dir, fs, path: `branch.${params.branch}.merge` });
|
|
305
|
+
if (merge === undefined || merge === null)
|
|
306
|
+
return undefined;
|
|
307
|
+
// merge is stored as refs/heads/<branch> — extract the branch name
|
|
308
|
+
const mergeStr = String(merge);
|
|
309
|
+
const remoteBranch = mergeStr.startsWith('refs/heads/') ? mergeStr.slice('refs/heads/'.length) : mergeStr;
|
|
310
|
+
return { remote: String(remote), remoteBranch };
|
|
311
|
+
}
|
|
312
|
+
async init(params) {
|
|
313
|
+
const dir = this.requireDirectory(params);
|
|
314
|
+
await git.init({ defaultBranch: params.defaultBranch ?? 'main', dir, fs });
|
|
315
|
+
}
|
|
316
|
+
async isAncestor(params) {
|
|
317
|
+
const dir = this.requireDirectory(params);
|
|
318
|
+
const commitOid = await git.resolveRef({ dir, fs, ref: params.commit });
|
|
319
|
+
const ancestorOid = await git.resolveRef({ dir, fs, ref: params.ancestor });
|
|
320
|
+
if (commitOid === ancestorOid)
|
|
321
|
+
return true;
|
|
322
|
+
return git.isDescendent({ ancestor: ancestorOid, depth: -1, dir, fs, oid: commitOid });
|
|
323
|
+
}
|
|
324
|
+
async isEmptyRepository(params) {
|
|
325
|
+
const dir = this.requireDirectory(params);
|
|
326
|
+
const commits = await this.log({ depth: 1, directory: dir });
|
|
327
|
+
if (commits.length > 0)
|
|
328
|
+
return false;
|
|
329
|
+
const remotes = await this.listRemotes({ directory: dir });
|
|
330
|
+
if (remotes.length > 0)
|
|
331
|
+
return false;
|
|
332
|
+
const branches = await git.listBranches({ dir, fs });
|
|
333
|
+
if (branches.length > 0)
|
|
334
|
+
return false;
|
|
335
|
+
const tags = await git.listTags({ dir, fs });
|
|
336
|
+
if (tags.length > 0)
|
|
337
|
+
return false;
|
|
338
|
+
const { isClean } = await this.status({ directory: dir });
|
|
339
|
+
return isClean;
|
|
340
|
+
}
|
|
341
|
+
async isInitialized(params) {
|
|
342
|
+
const dir = this.requireDirectory(params);
|
|
343
|
+
return fs.promises
|
|
344
|
+
.access(join(dir, '.git'))
|
|
345
|
+
.then(() => true)
|
|
346
|
+
.catch(() => false);
|
|
347
|
+
}
|
|
348
|
+
async listBranches(params) {
|
|
349
|
+
const dir = this.requireDirectory(params);
|
|
350
|
+
const current = await this.getCurrentBranch(params);
|
|
351
|
+
const branches = await git.listBranches({ dir, fs });
|
|
352
|
+
const result = branches.map((name) => ({ isCurrent: name === current, isRemote: false, name }));
|
|
353
|
+
if (params.remote) {
|
|
354
|
+
try {
|
|
355
|
+
const remoteBranches = await git.listBranches({ dir, fs, remote: params.remote });
|
|
356
|
+
for (const name of remoteBranches) {
|
|
357
|
+
if (name === 'HEAD')
|
|
358
|
+
continue;
|
|
359
|
+
result.push({ isCurrent: false, isRemote: true, name: `${params.remote}/${name}` });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
// No remote configured or no refs fetched yet — return local-only.
|
|
364
|
+
// Mirrors `git branch -a`: never auto-fetches, reads cached refs only.
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
async listRemotes(params) {
|
|
370
|
+
const dir = this.requireDirectory(params);
|
|
371
|
+
const remotes = await git.listRemotes({ dir, fs });
|
|
372
|
+
return remotes.map((r) => ({ remote: r.remote, url: r.url }));
|
|
373
|
+
}
|
|
374
|
+
async log(params) {
|
|
375
|
+
const dir = this.requireDirectory(params);
|
|
376
|
+
try {
|
|
377
|
+
const commits = await git.log({ depth: params.depth, dir, fs, ref: params.ref });
|
|
378
|
+
return commits.map((c) => ({
|
|
379
|
+
author: { email: c.commit.author.email, name: c.commit.author.name },
|
|
380
|
+
message: c.commit.message.trim(),
|
|
381
|
+
sha: c.oid,
|
|
382
|
+
timestamp: new Date(c.commit.author.timestamp * 1000),
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
// No commits yet — HEAD ref does not exist
|
|
387
|
+
if (error instanceof git.Errors.NotFoundError)
|
|
388
|
+
return [];
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async merge(params) {
|
|
393
|
+
const dir = this.requireDirectory(params);
|
|
394
|
+
const mergeHeadPath = join(dir, '.git', 'MERGE_HEAD');
|
|
395
|
+
const mergeMsgPath = join(dir, '.git', 'MERGE_MSG');
|
|
396
|
+
const author = params.author ?? this.getAuthor();
|
|
397
|
+
const message = params.message ?? `Merge branch '${params.branch}'`;
|
|
398
|
+
const currentBranch = await git.currentBranch({ dir, fs });
|
|
399
|
+
const localSha = currentBranch
|
|
400
|
+
? await git.resolveRef({ dir, fs, ref: `refs/heads/${currentBranch}` }).catch(() => null)
|
|
401
|
+
: null;
|
|
402
|
+
try {
|
|
403
|
+
const mergeResult = await git.merge({
|
|
404
|
+
abortOnConflict: false,
|
|
405
|
+
author,
|
|
406
|
+
committer: author,
|
|
407
|
+
dir,
|
|
408
|
+
fs,
|
|
409
|
+
message,
|
|
410
|
+
theirs: params.branch,
|
|
411
|
+
});
|
|
412
|
+
if (mergeResult.alreadyMerged) {
|
|
413
|
+
return { alreadyUpToDate: true, success: true };
|
|
414
|
+
}
|
|
415
|
+
// isomorphic-git merge only updates refs — checkout to apply changes to working tree
|
|
416
|
+
if (currentBranch) {
|
|
417
|
+
await git.checkout({ dir, fs, ref: currentBranch });
|
|
418
|
+
}
|
|
419
|
+
return { success: true };
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
if (error instanceof git.Errors.CheckoutConflictError) {
|
|
423
|
+
// Undo the merge commit — restore HEAD to pre-merge state so repo is left clean
|
|
424
|
+
if (localSha && currentBranch) {
|
|
425
|
+
await git.writeRef({ dir, force: true, fs, ref: `refs/heads/${currentBranch}`, value: localSha });
|
|
426
|
+
}
|
|
427
|
+
throw new GitError('Local changes would be overwritten by merge. Commit or discard your changes first.');
|
|
428
|
+
}
|
|
429
|
+
if (error instanceof git.Errors.MergeConflictError) {
|
|
430
|
+
// isomorphic-git does not write MERGE_HEAD/MERGE_MSG — write them so
|
|
431
|
+
// getConflicts() and --continue work post-restart
|
|
432
|
+
const theirsOid = await git.resolveRef({ dir, fs, ref: params.branch });
|
|
433
|
+
await fs.promises.writeFile(mergeHeadPath, `${theirsOid}\n`);
|
|
434
|
+
await fs.promises.writeFile(mergeMsgPath, `${message}\n`);
|
|
435
|
+
// isomorphic-git uses the branch name as marker label (<<<<<<< main);
|
|
436
|
+
// native git uses HEAD — rewrite markers to match git convention
|
|
437
|
+
if (currentBranch) {
|
|
438
|
+
await this.rewriteConflictMarkers(dir, currentBranch, this.conflictsFromError(error));
|
|
439
|
+
}
|
|
440
|
+
return { conflicts: this.conflictsFromError(error), success: false };
|
|
441
|
+
}
|
|
442
|
+
if (error instanceof git.Errors.MergeNotSupportedError) {
|
|
443
|
+
if (!params.allowUnrelatedHistories) {
|
|
444
|
+
throw new GitError('Refusing to merge unrelated histories. Use --allow-unrelated-histories to force.');
|
|
445
|
+
}
|
|
446
|
+
const oursRef = (await git.currentBranch({ dir, fs })) ?? 'HEAD';
|
|
447
|
+
return this.mergeUnrelatedHistories({ author, dir, message, oursRef, theirsRef: params.branch });
|
|
448
|
+
}
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
async pull(params) {
|
|
453
|
+
const dir = this.requireDirectory(params);
|
|
454
|
+
const token = this.requireToken();
|
|
455
|
+
const remote = params.remote ?? 'origin';
|
|
456
|
+
// Guard: if MERGE_HEAD exists, a previous merge is unresolved — refuse to pull
|
|
457
|
+
const hasPendingMerge = await fs.promises
|
|
458
|
+
.readFile(join(dir, '.git', 'MERGE_HEAD'), 'utf8')
|
|
459
|
+
.then(() => true)
|
|
460
|
+
.catch(() => false);
|
|
461
|
+
if (hasPendingMerge) {
|
|
462
|
+
throw new GitError('You have unresolved merge conflicts. Resolve them, stage the files, and commit before pulling again.');
|
|
463
|
+
}
|
|
464
|
+
// Fetch from remote
|
|
465
|
+
await git.fetch({
|
|
466
|
+
dir,
|
|
467
|
+
fs,
|
|
468
|
+
headers: this.buildBasicAuthHeaders(token.userId, token.sessionKey),
|
|
469
|
+
http,
|
|
470
|
+
onAuth: this.getOnAuth(),
|
|
471
|
+
onAuthFailure: this.getOnAuthFailure(),
|
|
472
|
+
remote,
|
|
473
|
+
...(params.branch ? { remoteRef: params.branch } : {}),
|
|
474
|
+
});
|
|
475
|
+
// Determine which remote-tracking branch to merge
|
|
476
|
+
const localBranch = params.branch ?? (await this.getCurrentBranch(params));
|
|
477
|
+
if (!localBranch)
|
|
478
|
+
throw new GitError('Cannot determine branch for pull');
|
|
479
|
+
// After fetch, check if already up to date
|
|
480
|
+
const localSha = await git.resolveRef({ dir, fs, ref: `refs/heads/${localBranch}` }).catch(() => null);
|
|
481
|
+
const remoteSha = await git.resolveRef({ dir, fs, ref: `refs/remotes/${remote}/${localBranch}` }).catch(() => null);
|
|
482
|
+
if (localSha && remoteSha && localSha === remoteSha) {
|
|
483
|
+
return { alreadyUpToDate: true, success: true };
|
|
484
|
+
}
|
|
485
|
+
// Empty local repo — fast-forward HEAD to remote tip (like native git pull on empty repo)
|
|
486
|
+
if (!localSha && remoteSha) {
|
|
487
|
+
await git.writeRef({ dir, force: true, fs, ref: `refs/heads/${localBranch}`, value: remoteSha });
|
|
488
|
+
await git.checkout({ dir, fs, ref: localBranch });
|
|
489
|
+
return { alreadyUpToDate: false, success: true };
|
|
490
|
+
}
|
|
491
|
+
// Step 2: working tree safety check (isomorphic-git does not do this automatically)
|
|
492
|
+
// Abort if any dirty local file would be overwritten by the incoming changes
|
|
493
|
+
if (localSha && remoteSha) {
|
|
494
|
+
const matrix = await git.statusMatrix({ dir, fs });
|
|
495
|
+
const dirtyFiles = matrix.filter((row) => row[2] !== 1 || row[3] !== 1).map((row) => String(row[0]));
|
|
496
|
+
const localRef = localSha;
|
|
497
|
+
const remoteRef = remoteSha;
|
|
498
|
+
const wouldBeOverwritten = await Promise.all(dirtyFiles.map(async (filepath) => {
|
|
499
|
+
const [localFileOid, remoteFileOid] = await Promise.all([
|
|
500
|
+
git
|
|
501
|
+
.readBlob({ dir, filepath, fs, oid: localRef })
|
|
502
|
+
.then((r) => r.oid)
|
|
503
|
+
.catch(() => null),
|
|
504
|
+
git
|
|
505
|
+
.readBlob({ dir, filepath, fs, oid: remoteRef })
|
|
506
|
+
.then((r) => r.oid)
|
|
507
|
+
.catch(() => null),
|
|
508
|
+
]);
|
|
509
|
+
return localFileOid !== remoteFileOid;
|
|
510
|
+
}));
|
|
511
|
+
if (wouldBeOverwritten.some(Boolean)) {
|
|
512
|
+
throw new GitError('Local changes would be overwritten by pull. Commit or discard your changes first.');
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
const author = params.author ?? this.getAuthor();
|
|
516
|
+
try {
|
|
517
|
+
const mergeResult = await git.merge({
|
|
518
|
+
abortOnConflict: false,
|
|
519
|
+
author,
|
|
520
|
+
committer: author,
|
|
521
|
+
dir,
|
|
522
|
+
fs,
|
|
523
|
+
theirs: `${remote}/${localBranch}`,
|
|
524
|
+
});
|
|
525
|
+
// isomorphic-git merge only updates refs/commits — checkout to apply file changes to workdir.
|
|
526
|
+
await git.checkout({ dir, fs, ref: localBranch });
|
|
527
|
+
return { alreadyUpToDate: mergeResult.alreadyMerged, success: true };
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
if (error instanceof git.Errors.MergeConflictError) {
|
|
531
|
+
// isomorphic-git does not write MERGE_HEAD/MERGE_MSG — write them so
|
|
532
|
+
// getConflicts() and --continue work post-restart
|
|
533
|
+
const theirsOid = await git.resolveRef({ dir, fs, ref: `refs/remotes/${remote}/${localBranch}` });
|
|
534
|
+
await fs.promises.writeFile(join(dir, '.git', 'MERGE_HEAD'), `${theirsOid}\n`);
|
|
535
|
+
await fs.promises.writeFile(join(dir, '.git', 'MERGE_MSG'), `Merge remote-tracking branch '${remote}/${localBranch}'\n`);
|
|
536
|
+
// Rewrite conflict markers: isomorphic-git uses branch name, git uses HEAD
|
|
537
|
+
await this.rewriteConflictMarkers(dir, localBranch, this.conflictsFromError(error));
|
|
538
|
+
return { conflicts: this.conflictsFromError(error), success: false };
|
|
539
|
+
}
|
|
540
|
+
if (error instanceof git.Errors.MergeNotSupportedError) {
|
|
541
|
+
if (!params.allowUnrelatedHistories) {
|
|
542
|
+
throw new GitError('Refusing to merge unrelated histories. Use --allow-unrelated-histories to force.');
|
|
543
|
+
}
|
|
544
|
+
const result = await this.mergeUnrelatedHistories({
|
|
545
|
+
author,
|
|
546
|
+
dir,
|
|
547
|
+
message: `Merge remote-tracking branch '${remote}/${localBranch}'`,
|
|
548
|
+
oursRef: localBranch,
|
|
549
|
+
theirsRef: `${remote}/${localBranch}`,
|
|
550
|
+
});
|
|
551
|
+
if (result.success) {
|
|
552
|
+
await git.checkout({ dir, fs, ref: localBranch });
|
|
553
|
+
}
|
|
554
|
+
return result;
|
|
555
|
+
}
|
|
556
|
+
if (error instanceof git.Errors.CheckoutConflictError) {
|
|
557
|
+
// Undo the merge commit — restore HEAD to pre-merge state so repo is left clean
|
|
558
|
+
if (localSha) {
|
|
559
|
+
await git.writeRef({ dir, force: true, fs, ref: `refs/heads/${localBranch}`, value: localSha });
|
|
560
|
+
}
|
|
561
|
+
throw new GitError('Local changes would be overwritten by pull. Commit or discard your changes first.');
|
|
562
|
+
}
|
|
563
|
+
throw error;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
async push(params) {
|
|
567
|
+
const dir = this.requireDirectory(params);
|
|
568
|
+
const token = this.requireToken();
|
|
569
|
+
try {
|
|
570
|
+
const branch = params.branch ?? (await git.currentBranch({ dir, fs })) ?? 'main';
|
|
571
|
+
const remote = params.remote ?? 'origin';
|
|
572
|
+
const localSha = await git.resolveRef({ dir, fs, ref: `refs/heads/${branch}` }).catch(() => null);
|
|
573
|
+
const remoteSha = await git.resolveRef({ dir, fs, ref: `refs/remotes/${remote}/${branch}` }).catch(() => null);
|
|
574
|
+
if (localSha && remoteSha && localSha === remoteSha) {
|
|
575
|
+
return { alreadyUpToDate: true, success: true };
|
|
576
|
+
}
|
|
577
|
+
await git.push({
|
|
578
|
+
dir,
|
|
579
|
+
fs,
|
|
580
|
+
headers: this.buildBasicAuthHeaders(token.userId, token.sessionKey),
|
|
581
|
+
http,
|
|
582
|
+
onAuth: this.getOnAuth(),
|
|
583
|
+
onAuthFailure: this.getOnAuthFailure(),
|
|
584
|
+
ref: branch,
|
|
585
|
+
remote,
|
|
586
|
+
});
|
|
587
|
+
return { alreadyUpToDate: false, success: true };
|
|
588
|
+
}
|
|
589
|
+
catch (error) {
|
|
590
|
+
if (error instanceof git.Errors.PushRejectedError) {
|
|
591
|
+
return { message: error.message, reason: 'non_fast_forward', success: false };
|
|
592
|
+
}
|
|
593
|
+
throw error;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
async removeRemote(params) {
|
|
597
|
+
const dir = this.requireDirectory(params);
|
|
598
|
+
await git.deleteRemote({ dir, fs, remote: params.remote });
|
|
599
|
+
}
|
|
600
|
+
async reset(params) {
|
|
601
|
+
const dir = this.requireDirectory(params);
|
|
602
|
+
const mode = params.mode ?? 'mixed';
|
|
603
|
+
const ref = params.ref ?? 'HEAD';
|
|
604
|
+
// Case 1: Path-scoped unstage — always mixed, ignores mode/ref
|
|
605
|
+
if (params.filePaths && params.filePaths.length > 0) {
|
|
606
|
+
return this.resetUnstage(dir, params.filePaths);
|
|
607
|
+
}
|
|
608
|
+
// Case 2: Whole-tree unstage (mixed mode, ref=HEAD, no filePaths)
|
|
609
|
+
if (mode === 'mixed' && ref === 'HEAD') {
|
|
610
|
+
return this.resetUnstage(dir);
|
|
611
|
+
}
|
|
612
|
+
// Cases 3-5: Reset to a specific ref (soft/mixed/hard)
|
|
613
|
+
// Empty repo (no commits) — HEAD doesn't exist. Git treats this as a silent no-op.
|
|
614
|
+
const headExists = await git.resolveRef({ dir, fs, ref: 'HEAD' }).then(() => true, () => false);
|
|
615
|
+
if (!headExists) {
|
|
616
|
+
return { filesChanged: 0, headSha: '' };
|
|
617
|
+
}
|
|
618
|
+
const targetSha = await this.resolveRefExpression(dir, ref);
|
|
619
|
+
const branch = await this.getCurrentBranch(params);
|
|
620
|
+
if (!branch) {
|
|
621
|
+
throw new GitError('Cannot reset in detached HEAD state.');
|
|
622
|
+
}
|
|
623
|
+
const previousSha = await git.resolveRef({ dir, fs, ref: 'HEAD' });
|
|
624
|
+
if (mode === 'soft') {
|
|
625
|
+
await git.writeRef({ dir, force: true, fs, ref: `refs/heads/${branch}`, value: targetSha });
|
|
626
|
+
return { filesChanged: 0, headSha: targetSha };
|
|
627
|
+
}
|
|
628
|
+
if (mode === 'hard') {
|
|
629
|
+
// Snapshot files in current HEAD to detect orphans after reset
|
|
630
|
+
const currentFiles = new Set(await git.listFiles({ dir, fs, ref: previousSha }));
|
|
631
|
+
const targetFiles = new Set(await git.listFiles({ dir, fs, ref: targetSha }));
|
|
632
|
+
// Move branch pointer
|
|
633
|
+
await git.writeRef({ dir, force: true, fs, ref: `refs/heads/${branch}`, value: targetSha });
|
|
634
|
+
// Restore working tree + index
|
|
635
|
+
await git.checkout({ dir, force: true, fs, ref: branch });
|
|
636
|
+
// Delete orphaned files (tracked in old HEAD but not in target)
|
|
637
|
+
for (const filepath of currentFiles) {
|
|
638
|
+
if (!targetFiles.has(filepath)) {
|
|
639
|
+
// eslint-disable-next-line no-await-in-loop
|
|
640
|
+
await fs.promises.unlink(join(dir, filepath)).catch(() => { });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
// Clean up merge state if present
|
|
644
|
+
await fs.promises.unlink(join(dir, '.git', 'MERGE_HEAD')).catch(() => { });
|
|
645
|
+
await fs.promises.unlink(join(dir, '.git', 'MERGE_MSG')).catch(() => { });
|
|
646
|
+
const filesChanged = [...currentFiles].filter((f) => !targetFiles.has(f)).length +
|
|
647
|
+
[...targetFiles].filter((f) => !currentFiles.has(f)).length;
|
|
648
|
+
return { filesChanged, headSha: targetSha };
|
|
649
|
+
}
|
|
650
|
+
// mode === 'mixed' with ref !== HEAD
|
|
651
|
+
// Move branch pointer, then reset index to match new HEAD (working tree untouched)
|
|
652
|
+
await git.writeRef({ dir, force: true, fs, ref: `refs/heads/${branch}`, value: targetSha });
|
|
653
|
+
// Reset every file in the index to match the target
|
|
654
|
+
const targetFiles = await git.listFiles({ dir, fs, ref: targetSha });
|
|
655
|
+
const matrix = await git.statusMatrix({ dir, fs });
|
|
656
|
+
const allPaths = new Set([...matrix.map((row) => String(row[0])), ...targetFiles]);
|
|
657
|
+
await Promise.all([...allPaths].map((filepath) => git.resetIndex({ dir, filepath, fs, ref: targetSha })));
|
|
658
|
+
return { filesChanged: allPaths.size, headSha: targetSha };
|
|
659
|
+
}
|
|
660
|
+
async setTrackingBranch(params) {
|
|
661
|
+
const dir = this.requireDirectory(params);
|
|
662
|
+
await git.setConfig({ dir, fs, path: `branch.${params.branch}.remote`, value: params.remote });
|
|
663
|
+
await git.setConfig({ dir, fs, path: `branch.${params.branch}.merge`, value: `refs/heads/${params.remoteBranch}` });
|
|
664
|
+
}
|
|
665
|
+
async status(params) {
|
|
666
|
+
const dir = this.requireDirectory(params);
|
|
667
|
+
const matrix = await git.statusMatrix({ dir, fs });
|
|
668
|
+
const files = this.parseMatrix(matrix);
|
|
669
|
+
return { files, isClean: files.length === 0 };
|
|
670
|
+
}
|
|
671
|
+
buildBasicAuthHeaders(userId, sessionKey) {
|
|
672
|
+
const credentials = Buffer.from(`${userId}:${sessionKey}`).toString('base64');
|
|
673
|
+
return { Authorization: `Basic ${credentials}` };
|
|
674
|
+
}
|
|
675
|
+
conflictsFromError(error) {
|
|
676
|
+
if (!IsomorphicGitService.isConflictError(error))
|
|
677
|
+
return [];
|
|
678
|
+
const conflictData = error.data;
|
|
679
|
+
if (!IsomorphicGitService.isMergeConflictData(conflictData))
|
|
680
|
+
return [];
|
|
681
|
+
const deletedPaths = new Set([...(conflictData.deleteByTheirs ?? []), ...(conflictData.deleteByUs ?? [])]);
|
|
682
|
+
const bothModifiedPaths = new Set(conflictData.bothModified ?? []);
|
|
683
|
+
return conflictData.filepaths
|
|
684
|
+
.map((path) => ({
|
|
685
|
+
path,
|
|
686
|
+
type: deletedPaths.has(path)
|
|
687
|
+
? 'deleted_modified'
|
|
688
|
+
: bothModifiedPaths.has(path)
|
|
689
|
+
? 'both_modified'
|
|
690
|
+
: 'both_added',
|
|
691
|
+
}))
|
|
692
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
693
|
+
}
|
|
694
|
+
getAuthor() {
|
|
695
|
+
const token = this.authStateStore.getToken();
|
|
696
|
+
if (!token)
|
|
697
|
+
throw new GitAuthError();
|
|
698
|
+
return {
|
|
699
|
+
email: token.userEmail,
|
|
700
|
+
name: token.userName ?? token.userEmail,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
getOnAuth() {
|
|
704
|
+
return () => {
|
|
705
|
+
const token = this.authStateStore.getToken();
|
|
706
|
+
if (!token)
|
|
707
|
+
throw new GitAuthError();
|
|
708
|
+
return {
|
|
709
|
+
password: token.sessionKey,
|
|
710
|
+
username: token.userId,
|
|
711
|
+
};
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
getOnAuthFailure() {
|
|
715
|
+
return () => {
|
|
716
|
+
throw new GitAuthError('Authentication failed. Try /login again.');
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Guard against staged changes that would be overwritten by checkout.
|
|
721
|
+
* isomorphic-git's checkout only detects unstaged conflicts — it silently
|
|
722
|
+
* overwrites staged changes, causing data loss. This method fills that gap
|
|
723
|
+
* to match native git behavior.
|
|
724
|
+
*/
|
|
725
|
+
async guardStagedConflicts(dir, sourceBranch, targetRef) {
|
|
726
|
+
const matrix = await git.statusMatrix({ dir, fs });
|
|
727
|
+
// Staged files: index (col 3) differs from HEAD (col 1)
|
|
728
|
+
// [1,_,2] modified+staged, [1,_,0] deleted+staged, [0,_,2] new+staged
|
|
729
|
+
const stagedFiles = matrix.filter(([, head, , stage]) => stage !== head).map(([filepath]) => String(filepath));
|
|
730
|
+
if (stagedFiles.length === 0)
|
|
731
|
+
return;
|
|
732
|
+
const sourceOid = await git.resolveRef({ dir, fs, ref: sourceBranch });
|
|
733
|
+
const targetOid = await git.resolveRef({ dir, fs, ref: targetRef });
|
|
734
|
+
const conflicting = [];
|
|
735
|
+
/* eslint-disable no-await-in-loop -- sequential file I/O is intentional here */
|
|
736
|
+
for (const filepath of stagedFiles) {
|
|
737
|
+
const sourceBlobOid = await this.readBlobOid(dir, sourceOid, filepath);
|
|
738
|
+
const targetBlobOid = await this.readBlobOid(dir, targetOid, filepath);
|
|
739
|
+
if (sourceBlobOid !== targetBlobOid) {
|
|
740
|
+
conflicting.push(filepath);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
/* eslint-enable no-await-in-loop */
|
|
744
|
+
if (conflicting.length > 0) {
|
|
745
|
+
throw new GitError('Your local changes to the following files would be overwritten by checkout:\n' +
|
|
746
|
+
conflicting.map((f) => `\t${f}`).join('\n') +
|
|
747
|
+
'\nPlease commit your changes or stash them before you switch branches.');
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Manual merge for unrelated histories (no common ancestor).
|
|
752
|
+
* isomorphic-git throws MergeNotSupportedError because it can't handle
|
|
753
|
+
* base=null at the root tree level. We bypass by combining both trees directly.
|
|
754
|
+
*/
|
|
755
|
+
async mergeUnrelatedHistories(params) {
|
|
756
|
+
const { author, dir, message, oursRef, theirsRef } = params;
|
|
757
|
+
const mergeHeadPath = join(dir, '.git', 'MERGE_HEAD');
|
|
758
|
+
const mergeMsgPath = join(dir, '.git', 'MERGE_MSG');
|
|
759
|
+
const oursSha = await git.resolveRef({ dir, fs, ref: oursRef });
|
|
760
|
+
const theirsSha = await git.resolveRef({ dir, fs, ref: theirsRef });
|
|
761
|
+
// List all files from both sides
|
|
762
|
+
const oursFiles = await git.listFiles({ dir, fs, ref: oursSha });
|
|
763
|
+
const theirsFiles = await git.listFiles({ dir, fs, ref: theirsSha });
|
|
764
|
+
const theirsSet = new Set(theirsFiles);
|
|
765
|
+
// Detect conflicts: same filepath on both sides with different content
|
|
766
|
+
const conflicts = [];
|
|
767
|
+
/* eslint-disable no-await-in-loop -- sequential file I/O is intentional here */
|
|
768
|
+
for (const filepath of oursFiles) {
|
|
769
|
+
if (!theirsSet.has(filepath))
|
|
770
|
+
continue;
|
|
771
|
+
const oursBlob = await git.readBlob({ dir, filepath, fs, oid: oursSha });
|
|
772
|
+
const theirsBlob = await git.readBlob({ dir, filepath, fs, oid: theirsSha });
|
|
773
|
+
if (oursBlob.oid !== theirsBlob.oid) {
|
|
774
|
+
conflicts.push({ path: filepath, type: 'both_added' });
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
/* eslint-enable no-await-in-loop */
|
|
778
|
+
if (conflicts.length > 0) {
|
|
779
|
+
// Write MERGE_HEAD/MERGE_MSG so --continue works
|
|
780
|
+
await fs.promises.writeFile(mergeHeadPath, `${theirsSha}\n`);
|
|
781
|
+
await fs.promises.writeFile(mergeMsgPath, `${message}\n`);
|
|
782
|
+
// Write conflict markers to working tree for each conflicted file
|
|
783
|
+
/* eslint-disable no-await-in-loop -- sequential per-file conflict marker writes */
|
|
784
|
+
for (const conflict of conflicts) {
|
|
785
|
+
const oursBlob = await git.readBlob({ dir, filepath: conflict.path, fs, oid: oursSha });
|
|
786
|
+
const theirsBlob = await git.readBlob({ dir, filepath: conflict.path, fs, oid: theirsSha });
|
|
787
|
+
const oursContent = Buffer.from(oursBlob.blob).toString('utf8');
|
|
788
|
+
const theirsContent = Buffer.from(theirsBlob.blob).toString('utf8');
|
|
789
|
+
const conflictContent = `<<<<<<< HEAD\n` +
|
|
790
|
+
oursContent +
|
|
791
|
+
(oursContent.endsWith('\n') ? '' : '\n') +
|
|
792
|
+
`=======\n` +
|
|
793
|
+
theirsContent +
|
|
794
|
+
(theirsContent.endsWith('\n') ? '' : '\n') +
|
|
795
|
+
`>>>>>>> ${theirsRef}\n`;
|
|
796
|
+
await fs.promises.writeFile(join(dir, conflict.path), conflictContent);
|
|
797
|
+
}
|
|
798
|
+
/* eslint-enable no-await-in-loop */
|
|
799
|
+
return { conflicts, success: false };
|
|
800
|
+
}
|
|
801
|
+
// No conflicts — write all remote files to working tree and stage them
|
|
802
|
+
const oursSet = new Set(oursFiles);
|
|
803
|
+
/* eslint-disable no-await-in-loop -- sequential file writes + git add */
|
|
804
|
+
for (const filepath of theirsFiles) {
|
|
805
|
+
if (oursSet.has(filepath))
|
|
806
|
+
continue; // same content, already present
|
|
807
|
+
const blob = await git.readBlob({ dir, filepath, fs, oid: theirsSha });
|
|
808
|
+
const filePath = join(dir, filepath);
|
|
809
|
+
const fileDir = join(filePath, '..');
|
|
810
|
+
await fs.promises.mkdir(fileDir, { recursive: true });
|
|
811
|
+
await fs.promises.writeFile(filePath, Buffer.from(blob.blob));
|
|
812
|
+
await git.add({ dir, filepath, fs });
|
|
813
|
+
}
|
|
814
|
+
/* eslint-enable no-await-in-loop */
|
|
815
|
+
// Create merge commit with both parents
|
|
816
|
+
await git.commit({
|
|
817
|
+
author,
|
|
818
|
+
committer: author,
|
|
819
|
+
dir,
|
|
820
|
+
fs,
|
|
821
|
+
message,
|
|
822
|
+
parent: [oursSha, theirsSha],
|
|
823
|
+
});
|
|
824
|
+
return { success: true };
|
|
825
|
+
}
|
|
826
|
+
// eslint-disable-next-line complexity
|
|
827
|
+
parseMatrix(matrix) {
|
|
828
|
+
const files = [];
|
|
829
|
+
for (const [filepath, head, workdir, stage] of matrix) {
|
|
830
|
+
const path = String(filepath);
|
|
831
|
+
if (head === 1 && workdir === 0 && stage === 0) {
|
|
832
|
+
files.push({ path, staged: true, status: 'deleted' }); // [1,0,0] staged deletion (git rm)
|
|
833
|
+
}
|
|
834
|
+
else if (head === 1 && workdir === 0 && stage === 1) {
|
|
835
|
+
files.push({ path, staged: false, status: 'deleted' }); // [1,0,1] unstaged deletion (rm without git rm)
|
|
836
|
+
}
|
|
837
|
+
else if (head === 1 && workdir === 0 && stage === 2) {
|
|
838
|
+
files.push({ path, staged: false, status: 'deleted' }); // [1,0,2] absent from disk, index differs from HEAD (e.g. post-merge-conflict)
|
|
839
|
+
}
|
|
840
|
+
else if (head === 1 && workdir === 0 && stage === 3) {
|
|
841
|
+
// [1,0,3] staged modification then deleted from disk → show both staged mod and unstaged deletion
|
|
842
|
+
files.push({ path, staged: true, status: 'modified' }, { path, staged: false, status: 'deleted' });
|
|
843
|
+
}
|
|
844
|
+
else if (head === 1 && workdir === 1 && stage === 0) {
|
|
845
|
+
files.push({ path, staged: true, status: 'deleted' }, { path, staged: false, status: 'untracked' }); // [1,1,0] git rm --cached: staged deletion + file still in workdir → untracked
|
|
846
|
+
}
|
|
847
|
+
else if (head === 1 && workdir === 2 && stage === 1) {
|
|
848
|
+
files.push({ path, staged: false, status: 'modified' }); // [1,2,1] unstaged modification
|
|
849
|
+
}
|
|
850
|
+
else if (head === 1 && workdir === 2 && stage === 2) {
|
|
851
|
+
files.push({ path, staged: true, status: 'modified' }); // [1,2,2] staged modification
|
|
852
|
+
}
|
|
853
|
+
else if (head === 1 && workdir === 2 && stage === 3) {
|
|
854
|
+
files.push({ path, staged: true, status: 'modified' }, { path, staged: false, status: 'modified' }); // [1,2,3] partially staged modification
|
|
855
|
+
}
|
|
856
|
+
else if (head === 0 && workdir === 2 && stage === 0) {
|
|
857
|
+
files.push({ path, staged: false, status: 'untracked' }); // [0,2,0] untracked new file
|
|
858
|
+
}
|
|
859
|
+
else if (head === 0 && workdir === 2 && stage === 2) {
|
|
860
|
+
files.push({ path, staged: true, status: 'added' }); // [0,2,2] staged new file
|
|
861
|
+
}
|
|
862
|
+
else if (head === 0 && workdir === 2 && stage === 3) {
|
|
863
|
+
files.push({ path, staged: true, status: 'added' }, { path, staged: false, status: 'modified' }); // [0,2,3] partially staged new file
|
|
864
|
+
}
|
|
865
|
+
// [1,1,1] unmodified → skip
|
|
866
|
+
}
|
|
867
|
+
return files;
|
|
868
|
+
}
|
|
869
|
+
async readBlobOid(dir, commitOid, filepath) {
|
|
870
|
+
try {
|
|
871
|
+
const result = await git.readBlob({ dir, filepath, fs, oid: commitOid });
|
|
872
|
+
return result.oid;
|
|
873
|
+
}
|
|
874
|
+
catch {
|
|
875
|
+
return null;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
requireDirectory(params) {
|
|
879
|
+
// Guard against empty string — undefined/null are caught by TypeScript at compile time
|
|
880
|
+
if (!params.directory)
|
|
881
|
+
throw new GitError('directory is required for git operations');
|
|
882
|
+
return params.directory;
|
|
883
|
+
}
|
|
884
|
+
requireToken() {
|
|
885
|
+
const token = this.authStateStore.getToken();
|
|
886
|
+
if (!token)
|
|
887
|
+
throw new GitAuthError();
|
|
888
|
+
return token;
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Unstage files by resetting their index entries to match HEAD.
|
|
892
|
+
* When filePaths is omitted, unstages all staged files.
|
|
893
|
+
*/
|
|
894
|
+
async resetUnstage(dir, filePaths) {
|
|
895
|
+
const headSha = await git.resolveRef({ dir, fs, ref: 'HEAD' }).catch(() => null);
|
|
896
|
+
const matrix = await git.statusMatrix({ dir, fs });
|
|
897
|
+
// Identify staged rows — any file where the index differs from HEAD
|
|
898
|
+
const stagedRows = matrix.filter(([, head, , stage]) => {
|
|
899
|
+
if (head === 1 && stage === 0)
|
|
900
|
+
return true; // staged deletion or git rm --cached ([1,0,0] and [1,1,0])
|
|
901
|
+
if (head === 0 && stage === 2)
|
|
902
|
+
return true; // staged new file ([0,2,2])
|
|
903
|
+
if (head === 1 && stage === 2)
|
|
904
|
+
return true; // staged modification ([1,2,2])
|
|
905
|
+
if (stage === 3)
|
|
906
|
+
return true; // partially staged ([*,*,3])
|
|
907
|
+
return false;
|
|
908
|
+
});
|
|
909
|
+
const toUnstage = filePaths && filePaths.length > 0
|
|
910
|
+
? stagedRows
|
|
911
|
+
.filter(([filepath]) => {
|
|
912
|
+
const path = String(filepath);
|
|
913
|
+
return filePaths.some((fp) => {
|
|
914
|
+
if (path === fp)
|
|
915
|
+
return true;
|
|
916
|
+
const prefix = fp.endsWith('/') ? fp : `${fp}/`;
|
|
917
|
+
return path.startsWith(prefix);
|
|
918
|
+
});
|
|
919
|
+
})
|
|
920
|
+
.map(([filepath]) => String(filepath))
|
|
921
|
+
: stagedRows.map(([filepath]) => String(filepath));
|
|
922
|
+
// Validate that every requested path is known to the repository
|
|
923
|
+
if (filePaths && filePaths.length > 0) {
|
|
924
|
+
const allKnownPaths = new Set(matrix.map(([filepath]) => String(filepath)));
|
|
925
|
+
for (const fp of filePaths) {
|
|
926
|
+
const isKnown = fp.endsWith('/') ? [...allKnownPaths].some((p) => p.startsWith(fp)) : allKnownPaths.has(fp);
|
|
927
|
+
if (!isKnown) {
|
|
928
|
+
throw new GitError(`pathspec '${fp}' did not match any file(s) known to git`);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
await Promise.all(headSha
|
|
933
|
+
? toUnstage.map((filepath) => git.resetIndex({ dir, filepath, fs, ref: 'HEAD' }))
|
|
934
|
+
: toUnstage.map((filepath) => git.remove({ dir, filepath, fs })));
|
|
935
|
+
return { filesChanged: toUnstage.length, headSha: headSha ?? '' };
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Resolves a ref expression that may include ~N ancestry syntax (e.g. HEAD~2).
|
|
939
|
+
* Falls back to git.resolveRef for plain refs.
|
|
940
|
+
*/
|
|
941
|
+
async resolveRefExpression(dir, ref) {
|
|
942
|
+
const tildeMatch = /^(.+)~(\d+)$/.exec(ref);
|
|
943
|
+
if (!tildeMatch) {
|
|
944
|
+
return git.resolveRef({ dir, fs, ref });
|
|
945
|
+
}
|
|
946
|
+
const baseRef = tildeMatch[1];
|
|
947
|
+
const count = Number.parseInt(tildeMatch[2], 10);
|
|
948
|
+
if (count === 0) {
|
|
949
|
+
return git.resolveRef({ dir, fs, ref: baseRef });
|
|
950
|
+
}
|
|
951
|
+
let oid = await git.resolveRef({ dir, fs, ref: baseRef });
|
|
952
|
+
for (let i = 0; i < count; i++) {
|
|
953
|
+
// eslint-disable-next-line no-await-in-loop
|
|
954
|
+
const commit = await git.readCommit({ dir, fs, oid });
|
|
955
|
+
if (commit.commit.parent.length === 0) {
|
|
956
|
+
throw new GitError(`Cannot resolve '${ref}': not enough ancestors.`);
|
|
957
|
+
}
|
|
958
|
+
oid = commit.commit.parent[0];
|
|
959
|
+
}
|
|
960
|
+
return oid;
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Fixes conflict markers written by isomorphic-git to match native git:
|
|
964
|
+
* 1. Replaces `<<<<<<< <branchName>` with `<<<<<<< HEAD`
|
|
965
|
+
* 2. Ensures `\n` before `=======` and `>>>>>>>` (isomorphic-git omits it when content has no trailing newline)
|
|
966
|
+
*/
|
|
967
|
+
async rewriteConflictMarkers(dir, branchName, conflicts) {
|
|
968
|
+
const marker = `<<<<<<< ${branchName}`;
|
|
969
|
+
await Promise.all(conflicts
|
|
970
|
+
.filter((c) => c.type !== 'deleted_modified')
|
|
971
|
+
.map(async (c) => {
|
|
972
|
+
const filePath = join(dir, c.path);
|
|
973
|
+
let content = await fs.promises.readFile(filePath, 'utf8').catch(() => null);
|
|
974
|
+
if (!content)
|
|
975
|
+
return;
|
|
976
|
+
content = content.replaceAll(marker, '<<<<<<< HEAD');
|
|
977
|
+
// isomorphic-git omits \n before ======= and >>>>>>> when file content has no trailing newline
|
|
978
|
+
content = content.replaceAll(/([^\n])=======/g, '$1\n=======');
|
|
979
|
+
content = content.replaceAll(/([^\n])>>>>>>>/g, '$1\n>>>>>>>');
|
|
980
|
+
await fs.promises.writeFile(filePath, content);
|
|
981
|
+
}));
|
|
982
|
+
}
|
|
983
|
+
}
|