From c6102d33d07091775b9c0f1dedb17366cb1c0351 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 13 Jun 2026 21:59:14 +0900 Subject: [PATCH] feat: platformless git operation engine implementation - Update control plane phase roadmap - Move repo-registry milestone to archive - Implement git engine command execution - Add postgres repository storage layer - Update contracts documentation - Add test cases for storage and git engine --- .../repo-registry-and-workspace-lease.md | 29 +- .../phase/control-plane-foundation/PHASE.md | 4 +- .../code_review_local_G05_0.log | 198 ++++++++ .../complete.log | 45 ++ .../plan_local_G05_0.log | 243 ++++++++++ .../contracts/notes/gito-control-plane.md | 7 + services/core/internal/gitengine/command.go | 52 ++- .../core/internal/gitengine/command_test.go | 386 ++++++++++++++++ services/core/internal/storage/postgres.go | 432 +++++++++++++++++- .../internal/storage/postgres_repo_test.go | 115 +++++ .../core/internal/storage/postgres_test.go | 228 +++++++++ services/core/internal/storage/storage.go | 32 +- .../core/internal/storage/storage_test.go | 16 +- 13 files changed, 1755 insertions(+), 32 deletions(-) rename agent-roadmap/{ => archive}/phase/control-plane-foundation/milestones/repo-registry-and-workspace-lease.md (66%) create mode 100644 agent-task/archive/2026/06/m-platformless-git-operation-engine/code_review_local_G05_0.log create mode 100644 agent-task/archive/2026/06/m-platformless-git-operation-engine/complete.log create mode 100644 agent-task/archive/2026/06/m-platformless-git-operation-engine/plan_local_G05_0.log create mode 100644 services/core/internal/storage/postgres_repo_test.go diff --git a/agent-roadmap/phase/control-plane-foundation/milestones/repo-registry-and-workspace-lease.md b/agent-roadmap/archive/phase/control-plane-foundation/milestones/repo-registry-and-workspace-lease.md similarity index 66% rename from agent-roadmap/phase/control-plane-foundation/milestones/repo-registry-and-workspace-lease.md rename to agent-roadmap/archive/phase/control-plane-foundation/milestones/repo-registry-and-workspace-lease.md index 615b08d..92a77b9 100644 --- a/agent-roadmap/phase/control-plane-foundation/milestones/repo-registry-and-workspace-lease.md +++ b/agent-roadmap/archive/phase/control-plane-foundation/milestones/repo-registry-and-workspace-lease.md @@ -11,7 +11,7 @@ ## 상태 -[진행중] +[완료] ## 승격 조건 @@ -36,27 +36,30 @@ Gito가 관리할 repository와 기본 branch/workspace root/credential ref를 저장한다. -- [ ] [repo-model] `Repo` model과 Postgres schema를 정리한다. 검증: migration에 `remote_url`, `default_branch`, `workspace_root`, `credential_ref`가 포함된다. -- [ ] [repo-store] repo create/get/list/update store boundary를 만든다. 검증: storage test가 중복 id와 필수 필드 validation을 확인한다. -- [ ] [repo-contract] proto-socket `repo` channel 후보 DTO와 REST bootstrap 예외를 contracts에 기록한다. +- [x] [repo-model] `Repo` model과 Postgres schema를 정리한다. 검증: migration에 `remote_url`, `default_branch`, `workspace_root`, `credential_ref`가 포함된다. +- [x] [repo-store] repo create/get/list/update store boundary를 만든다. 검증: storage test가 중복 id와 필수 필드 validation을 확인한다. +- [x] [repo-contract] proto-socket `repo` channel 후보 DTO와 REST bootstrap 예외를 contracts에 기록한다. ### Epic: [lease] Workspace lease 작업 slot을 안전하게 예약하고 해제하는 원장을 만든다. -- [ ] [lease-model] `WorkspaceLease` state를 `available`, `leased`, `dirty`, `error` 기준으로 확정한다. -- [ ] [lease-store] DB transaction으로 하나의 available slot만 lease하는 store boundary를 만든다. 검증: 동시 lease 시 같은 slot이 중복 반환되지 않는 테스트를 둔다. -- [ ] [lease-release] release, dirty, error 전환 기준을 operation 결과와 연결한다. +- [x] [lease-model] `WorkspaceLease` state를 `available`, `leased`, `dirty`, `error` 기준으로 확정한다. +- [x] [lease-store] DB transaction으로 하나의 available slot만 lease하는 store boundary를 만든다. 검증: 동시 lease 시 같은 slot이 중복 반환되지 않는 테스트를 둔다. +- [x] [lease-release] release, dirty, error 전환 기준을 operation 결과와 연결한다. ## 완료 리뷰 -- 상태: 없음 -- 요청일: 없음 -- 완료 근거: 없음 +- 상태: 승인됨 +- 요청일: 2026-06-13 +- 완료 근거: + - registry/lease model, Postgres schema, store boundary, contract note가 현재 Milestone 기능 Task 범위에 맞게 정리됐다. + - repo create/get/list/update와 workspace lease acquire/release/dirty/error transition 테스트를 추가했다. + - `go test ./...`, `go vet ./...`, `git diff --check`가 통과했다. - 리뷰 필요: - - [ ] 사용자가 완료 결과를 확인했다 - - [ ] archive 이동을 승인했다 -- 리뷰 코멘트: 없음 + - [x] 사용자가 완료 결과를 확인했다 + - [x] archive 이동을 승인했다 +- 리뷰 코멘트: 코드 레벨 완료 검토에서 Required/Suggested 이슈 없음. Postgres 통합 테스트는 `GITO_TEST_DATABASE_URL`이 없으면 skip된다. ## 범위 제외 diff --git a/agent-roadmap/phase/control-plane-foundation/PHASE.md b/agent-roadmap/phase/control-plane-foundation/PHASE.md index 42b16c4..657fdf7 100644 --- a/agent-roadmap/phase/control-plane-foundation/PHASE.md +++ b/agent-roadmap/phase/control-plane-foundation/PHASE.md @@ -22,8 +22,8 @@ Gito의 초기 control plane을 build 가능한 modular monolith로 구성한다 - 경로: `agent-roadmap/phase/control-plane-foundation/milestones/runtime-scaffold-and-contract-baseline.md` - 요약: Go command, Flutter control surface placeholder, contracts, agent-ops, README, architecture 문서를 실행 가능한 기준선으로 정리한다. -- [진행중] Repo Registry and Workspace Lease - - 경로: `agent-roadmap/phase/control-plane-foundation/milestones/repo-registry-and-workspace-lease.md` +- [완료] Repo Registry and Workspace Lease + - 경로: `agent-roadmap/archive/phase/control-plane-foundation/milestones/repo-registry-and-workspace-lease.md` - 요약: 관리 대상 repo와 workspace slot/lease를 Postgres source of truth로 저장하고 안전하게 할당한다. - [계획] Platformless Git Operation Engine diff --git a/agent-task/archive/2026/06/m-platformless-git-operation-engine/code_review_local_G05_0.log b/agent-task/archive/2026/06/m-platformless-git-operation-engine/code_review_local_G05_0.log new file mode 100644 index 0000000..236b4d1 --- /dev/null +++ b/agent-task/archive/2026/06/m-platformless-git-operation-engine/code_review_local_G05_0.log @@ -0,0 +1,198 @@ + + +# Code Review Reference - OPS + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-06-13 +task=m-platformless-git-operation-engine, plan=0, tag=OPS + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/control-plane-foundation/milestones/platformless-git-operation-engine.md` +- Task ids: + - `clone-fetch`: clone/fetch operation을 구현한다. + - `status-diff`: status/diff operation을 구현한다. + - `commit-push`: commit/push operation을 구현한다. + - `branch-checkout`: branch checkout과 source branch 기준을 구현한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-local-G05.md` → `code_review_local_G05_N.log`, `PLAN-local-G05.md` → `plan_local_G05_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-platformless-git-operation-engine/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [OPS-1] Clone/Fetch Local Bare Repo Integration | [x] | +| [OPS-2] Status/Diff Integration States | [x] | +| [OPS-3] Commit/Push Remote Revision | [x] | +| [OPS-4] Source Branch Checkout API | [x] | + +## 구현 체크리스트 + +- [x] local bare repo test helper를 `command_test.go`에 추가하고 clone/fetch integration이 remote revision을 확인하게 한다. +- [x] status/diff integration test를 추가해 clean, unstaged, staged 상태와 `Diff`/`DiffOptions{Staged:true}` 출력을 확인한다. +- [x] commit/push integration test를 추가해 local git user config를 설정한 뒤 remote branch revision 반영을 확인한다. +- [x] source branch 기준 checkout API와 단위/integration test를 추가해 branch 변경 후 worktree revision을 확인한다. +- [x] `cd services/core && go test -count=1 ./internal/gitengine`, `cd services/core && go test ./...`, `git diff --check`를 실행하고 결과를 review stub에 붙인다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G05_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_local_G05_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/m-platformless-git-operation-engine/`를 `agent-task/archive/YYYY/MM/m-platformless-git-operation-engine/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-platformless-git-operation-engine/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G05.md`와 `CODE_REVIEW-local-G05.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +없음. + +## 주요 설계 결정 + +- `CheckoutBranch` API를 도입하여 `CheckoutOptions`를 통해 `Branch` 지정뿐만 아니라 `StartPoint` 및 `Reset` 파라미터를 추가로 받을 수 있게 지원함으로써 source branch 기준 checkout을 안정적으로 구동시켰습니다. +- `command_test.go`에 독립적인 bare repository 및 seed repository 초기화 헬퍼(`setupBareRemote`)를 구축하여 로컬 호스트 환경의 글로벌 git config(특히 user.name/email 설정 및 GPG 서명 강제 여부)에 간섭받지 않고 온전하게 git integration 동작들을 반복 수행할 수 있게 조치했습니다. +- seed repository 생성 시 Git 기본 branch 설정 편차를 피하기 위해 첫 커밋 직후 명시적으로 `git branch -M main`을 수행해 `main` branch를 확립했습니다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- local bare repo integration tests가 실제 `git` CLI를 사용하며 global user config에 의존하지 않는지 확인한다. +- `Checkout` 기존 호출 호환성이 유지되고, source branch API가 `StartPoint`와 `Reset` semantics를 명확히 검증하는지 확인한다. +- `Roadmap Targets` 네 Task id가 실제 완료 가능한 증거와 일치하는지 확인한다. + +## 검증 결과 + +각 중간 검증 및 최종 검증 명령 실행 결과 출력입니다. + +### OPS-1 중간 검증 +``` +$ cd services/core && go test -count=1 ./internal/gitengine -run TestCloneFetchWithLocalBareRemote +ok git.toki-labs.com/toki/gito/services/core/internal/gitengine 0.191s +``` + +### OPS-2 중간 검증 +``` +$ cd services/core && go test -count=1 ./internal/gitengine -run TestStatusDiffWithCleanStagedUnstagedWorkspace +ok git.toki-labs.com/toki/gito/services/core/internal/gitengine 0.171s +``` + +### OPS-3 중간 검증 +``` +$ cd services/core && go test -count=1 ./internal/gitengine -run TestCommitPushToLocalBareRemote +ok git.toki-labs.com/toki/gito/services/core/internal/gitengine 0.156s +``` + +### OPS-4 중간 검증 +``` +$ cd services/core && go test -count=1 ./internal/gitengine -run 'TestCheckoutBranchFromSource|TestCloneFetchCheckout' +ok git.toki-labs.com/toki/gito/services/core/internal/gitengine 0.191s +``` + +### 최종 검증 +``` +$ cd services/core && go test -count=1 ./internal/gitengine +ok git.toki-labs.com/toki/gito/services/core/internal/gitengine 0.647s + +$ cd services/core && go test ./... +? git.toki-labs.com/toki/gito/services/core/cmd/server [no test files] +? git.toki-labs.com/toki/gito/services/core/cmd/shell [no test files] +? git.toki-labs.com/toki/gito/services/core/cmd/worker [no test files] +? git.toki-labs.com/toki/gito/services/core/internal/agentshell [no test files] +ok git.toki-labs.com/toki/gito/services/core/internal/config (cached) +ok git.toki-labs.com/toki/gito/services/core/internal/controlplane (cached) +? git.toki-labs.com/toki/gito/services/core/internal/core [no test files] +? git.toki-labs.com/toki/gito/services/core/internal/events [no test files] +ok git.toki-labs.com/toki/gito/services/core/internal/gitengine 0.816s +ok git.toki-labs.com/toki/gito/services/core/internal/protosocket (cached) +? git.toki-labs.com/toki/gito/services/core/internal/provider [no test files] +ok git.toki-labs.com/toki/gito/services/core/internal/provider/forgejo (cached) +ok git.toki-labs.com/toki/gito/services/core/internal/storage (cached) +? git.toki-labs.com/toki/gito/services/core/internal/worker [no test files] + +$ git diff --check +(아무런 출력이 없으며 정상 종료됨) +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +Sections and their ownership: + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) | +| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS | +| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` -> `[x]` only | +| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` -> `[x]` only; final checkbox is mandatory before saving | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required to proceed; do not ask the user directly during implementation; when filled, include exact decision, evidence, commands/output, why automatic follow-up cannot resolve it, and resume condition | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan | +| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry | +| 코드리뷰 결과 | Review agent appends | Not included in stub | + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - Correctness: Pass + - Completeness: Pass + - Test coverage: Pass + - API contract: Pass + - Code quality: Pass + - Plan deviation: Pass + - Verification trust: Pass +- 발견된 문제: 없음 +- 리뷰어 검증: + - `cd services/core && go test -count=1 ./internal/gitengine` - PASS; `ok git.toki-labs.com/toki/gito/services/core/internal/gitengine 0.644s` + - `cd services/core && go test ./...` - PASS; core module 전체 테스트 성공 + - `git diff --check` - PASS; 출력 없음 +- 다음 단계: PASS 절차에 따라 plan/review 로그 아카이브, `complete.log` 작성, task directory archive 이동을 수행한다. diff --git a/agent-task/archive/2026/06/m-platformless-git-operation-engine/complete.log b/agent-task/archive/2026/06/m-platformless-git-operation-engine/complete.log new file mode 100644 index 0000000..76449ce --- /dev/null +++ b/agent-task/archive/2026/06/m-platformless-git-operation-engine/complete.log @@ -0,0 +1,45 @@ +# Complete - m-platformless-git-operation-engine + +## 완료 일시 + +2026-06-13 + +## 요약 + +platformless git operation engine ops 범위의 clone/fetch, status/diff, commit/push, source branch checkout 구현과 local bare repo 검증을 1회 리뷰 루프로 PASS 종결했다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G05_0.log` | `code_review_local_G05_0.log` | PASS | 계획된 gitengine API/test 범위 구현과 필수 검증을 확인했다. | + +## 구현/정리 내용 + +- `CheckoutBranch`와 `CheckoutOptions`를 추가해 source branch/start point 기준 checkout과 reset checkout을 지원했다. +- `Diff`와 `DiffOptions` 동작을 fake runner 및 local worktree 상태 기반 테스트로 확인했다. +- local bare repo 기반 clone/fetch, commit/push, branch checkout integration tests를 추가했다. + +## 최종 검증 + +- `cd services/core && go test -count=1 ./internal/gitengine` - PASS; `ok git.toki-labs.com/toki/gito/services/core/internal/gitengine 0.644s` +- `cd services/core && go test ./...` - PASS; core module 전체 테스트 성공 +- `git diff --check` - PASS; 출력 없음 + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/control-plane-foundation/milestones/platformless-git-operation-engine.md` +- Completed task ids: + - `clone-fetch`: PASS; evidence=`agent-task/archive/2026/06/m-platformless-git-operation-engine/plan_local_G05_0.log`, `agent-task/archive/2026/06/m-platformless-git-operation-engine/code_review_local_G05_0.log`; verification=`cd services/core && go test -count=1 ./internal/gitengine` + - `status-diff`: PASS; evidence=`agent-task/archive/2026/06/m-platformless-git-operation-engine/plan_local_G05_0.log`, `agent-task/archive/2026/06/m-platformless-git-operation-engine/code_review_local_G05_0.log`; verification=`cd services/core && go test -count=1 ./internal/gitengine` + - `commit-push`: PASS; evidence=`agent-task/archive/2026/06/m-platformless-git-operation-engine/plan_local_G05_0.log`, `agent-task/archive/2026/06/m-platformless-git-operation-engine/code_review_local_G05_0.log`; verification=`cd services/core && go test -count=1 ./internal/gitengine` + - `branch-checkout`: PASS; evidence=`agent-task/archive/2026/06/m-platformless-git-operation-engine/plan_local_G05_0.log`, `agent-task/archive/2026/06/m-platformless-git-operation-engine/code_review_local_G05_0.log`; verification=`cd services/core && go test -count=1 ./internal/gitengine` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-platformless-git-operation-engine/plan_local_G05_0.log b/agent-task/archive/2026/06/m-platformless-git-operation-engine/plan_local_G05_0.log new file mode 100644 index 0000000..ab6660e --- /dev/null +++ b/agent-task/archive/2026/06/m-platformless-git-operation-engine/plan_local_G05_0.log @@ -0,0 +1,243 @@ + + +# Plan - OPS + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-local-G05.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 필수 마지막 단계다. 구현 후 검증 명령을 실행하고, active 파일은 그대로 둔 채 리뷰 준비 상태를 보고한다. 종결 판정, 로그 아카이브, `complete.log` 작성, task directory archive 이동은 code-review 스킬 전용이다. 구현 중 사용자 결정, 사용자 소유 외부 환경, scope conflict 없이는 안전하게 진행할 수 없으면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 기록하고 멈춘다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나 `USER_REVIEW.md`, `complete.log`, archive 로그를 만들지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 증거 공백은 사용자 리뷰 요청이 아니다. + +## 배경 + +현재 마일스톤의 `ops` 에픽은 Git CLI와 local bare repo만으로 clone/fetch, status/diff, commit/push, branch checkout을 검증해야 한다. 기존 `gitengine`은 명령 래퍼와 fake runner 단위 테스트가 있으나, 로드맵의 `검증:` 문구가 요구하는 실제 local bare repo 기반 증거가 부족하다. pre-plan 작은 처리로 `Diff` 래퍼와 fake runner 단위 테스트는 추가되었으므로, 이 plan은 남은 통합 검증과 source branch 기준 checkout API를 완성한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 차단 사유는 active `CODE_REVIEW-local-G05.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 이 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`에서 복사된 형식이며, 구현 중 direct user prompt는 금지된다. code-review가 차단 요청을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/control-plane-foundation/milestones/platformless-git-operation-engine.md` +- Task ids: + - `clone-fetch`: clone/fetch operation을 구현한다. + - `status-diff`: status/diff operation을 구현한다. + - `commit-push`: commit/push operation을 구현한다. + - `branch-checkout`: branch checkout과 source branch 기준을 구현한다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/private/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-ops/skills/common/router.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` +- `agent-ops/rules/project/domain/workspace-ops/rules.md` +- `agent-ops/rules/project/domain/core/rules.md` +- `agent-roadmap/current.md` +- `agent-roadmap/phase/control-plane-foundation/PHASE.md` +- `agent-roadmap/phase/control-plane-foundation/milestones/platformless-git-operation-engine.md` +- `agent-test/local/rules.md` +- `agent-test/local/core-smoke.md` +- `agent-test/local/workspace-ops-smoke.md` +- `.gitignore` +- `services/core/go.mod` +- `services/core/internal/core/model.go` +- `services/core/internal/storage/storage.go` +- `services/core/internal/gitengine/command.go` +- `services/core/internal/gitengine/command_test.go` + +### 테스트 환경 규칙 + +- `test_env`: `local` +- `agent-test/local/rules.md`: 존재하며 읽음. core 변경은 `core-smoke`, agent-task artifact 변경은 `workspace-ops-smoke` 라우팅에 해당한다. +- 읽은 profile: + - `agent-test/local/core-smoke.md`: core 코드 변경은 최소 `cd services/core && go test ./...`로 확인한다. gitengine behavior 변경은 해당 동작을 검증하는 테스트가 필요하다. + - `agent-test/local/workspace-ops-smoke.md`: docs/roadmap/task artifact 변경은 `git diff --check`를 사용한다. +- 적용 명령: + - focused: `cd services/core && go test -count=1 ./internal/gitengine` + - core smoke: `cd services/core && go test ./...` + - workspace preflight: `git diff --check` +- `<확인 필요>` 값: 없음. +- fallback verification: 없음. local 규칙과 profile이 명확하다. + +### 테스트 커버리지 공백 + +- `clone-fetch`: `TestCloneFetchCheckout`는 fake runner command construction만 검증한다. local bare repo remote로 clone/fetch 후 revision 확인이 없다. +- `status-diff`: `Status`와 pre-plan `Diff` fake runner tests는 명령 인자만 검증한다. clean, unstaged, staged 상태를 실제 worktree에서 확인하는 테스트가 없다. +- `commit-push`: fake runner test만 있으며, local bare repo remote branch에 push revision이 반영되는지 검증하지 않는다. +- `branch-checkout`: 기존 `Checkout`은 `git checkout `만 감싸며 `source branch`/start-point 기준 branch 생성 또는 reset API가 없다. 실제 branch 변경 후 worktree revision 확인도 없다. + +### 심볼 참조 + +- renamed/removed symbol: none. +- added symbol already present before this plan: `Diff`, `DiffOptions` in `services/core/internal/gitengine/command.go`; references are limited to `services/core/internal/gitengine/command_test.go`. + +### 분할 판단 + +분할 정책을 plan file 선택 전에 평가했다. split 후보는 roadmap task 네 개였지만 모두 `services/core/internal/gitengine/command.go`와 `command_test.go` 한 ownership boundary 안에서 구현된다. local bare repo test helper를 각 subtask가 중복하거나 같은 test file을 순차적으로 수정하게 되어 coordination overhead가 더 크다. API/foundation과 broad call-site rollout 경계가 없고, external provider/storage/controlplane 변경도 제외되어 전체 변경은 한 리뷰에서 판단 가능하다. 따라서 단일 plan을 `agent-task/m-platformless-git-operation-engine/`에 둔다. + +### 범위 결정 근거 + +- 포함: `services/core/internal/gitengine/command.go`, `services/core/internal/gitengine/command_test.go`. +- 제외: `services/core/internal/storage/**`, `services/core/internal/controlplane/**`, `packages/contracts/**`, provider adapter, operation DTO, timeout/stderr structured result, `scan` 에픽. 이 plan은 사용자가 요청한 현재 마일스톤의 `ops` 에픽 완료에 한정한다. +- 기존 작업트리에는 storage/roadmap/contracts 변경이 있으나 이 plan의 구현 대상이 아니므로 수정하지 않는다. + +### 빌드 등급 + +- build lane/grade: `local-G05` +- rationale: core 내부 한 패키지의 bounded 변경이며 검증은 local git binary와 Go tests로 결정 가능하지만, 실제 bare repo integration과 branch start-point semantics가 있어 중간 등급으로 둔다. + +## 구현 체크리스트 + +- [ ] local bare repo test helper를 `command_test.go`에 추가하고 clone/fetch integration이 remote revision을 확인하게 한다. +- [ ] status/diff integration test를 추가해 clean, unstaged, staged 상태와 `Diff`/`DiffOptions{Staged:true}` 출력을 확인한다. +- [ ] commit/push integration test를 추가해 local git user config를 설정한 뒤 remote branch revision 반영을 확인한다. +- [ ] source branch 기준 checkout API와 단위/integration test를 추가해 branch 변경 후 worktree revision을 확인한다. +- [ ] `cd services/core && go test -count=1 ./internal/gitengine`, `cd services/core && go test ./...`, `git diff --check`를 실행하고 결과를 review stub에 붙인다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [OPS-1] Clone/Fetch Local Bare Repo Integration + +문제: `Clone`/`Fetch`는 wrapper 자체는 있으나 `services/core/internal/gitengine/command_test.go:92`의 fake runner 테스트만 있어 milestone의 local bare repo 검증을 충족하지 못한다. + +해결 방법: `command_test.go`에 temp dir 기반 helper를 추가한다. helper는 `git init --bare`, seed repo init, local user config, initial commit, push를 수행하고 remote path, seed path, expected revision을 반환한다. `TestCloneFetchWithLocalBareRemote`를 추가해 `Clone(CLI{}, CloneOptions{Branch:"main"})`, seed commit 추가, `Fetch(CLI{}, worktree, "")`, `rev-parse origin/main` 확인을 수행한다. + +수정 파일 및 체크리스트: +- [ ] `services/core/internal/gitengine/command_test.go`: `os`, `os/exec`, `path/filepath`, `strings` import를 추가한다. +- [ ] `services/core/internal/gitengine/command_test.go`: `runGit`, `gitOutput`, `writeFile`, `setupBareRemote` test helper를 추가한다. +- [ ] `services/core/internal/gitengine/command_test.go`: `TestCloneFetchWithLocalBareRemote`를 추가한다. + +테스트 작성: 작성한다. Test name은 `TestCloneFetchWithLocalBareRemote`; assertion은 fetched `origin/main` revision이 bare remote `main` revision과 같은지 확인한다. fixture는 `t.TempDir()` 하위 bare repo와 seed/worktree repo다. + +중간 검증: +```bash +cd services/core && go test -count=1 ./internal/gitengine -run TestCloneFetchWithLocalBareRemote +``` +기대 결과: `ok git.toki-labs.com/toki/gito/services/core/internal/gitengine`. + +### [OPS-2] Status/Diff Integration States + +문제: `Status`는 `services/core/internal/gitengine/command.go:90`에서 porcelain status를 반환하고, `Diff`는 `services/core/internal/gitengine/command.go:104`에서 unstaged/staged diff를 감싼다. 그러나 `services/core/internal/gitengine/command_test.go:37`과 `:51` 주변 fake runner tests만 있어 clean, unstaged, staged 실제 worktree 상태를 보장하지 않는다. + +해결 방법: `TestStatusDiffWithCleanStagedUnstagedWorkspace`를 추가한다. helper가 만든 clone worktree에서 처음에는 `Status.Clean == true`를 확인한다. 파일을 수정한 뒤 `Status.Clean == false`와 `Diff(CLI{}, worktree, DiffOptions{})`에 수정 내용이 포함되는지 확인한다. `git add` 후 `Diff(CLI{}, worktree, DiffOptions{Staged:true})`가 staged 내용을 포함하고 unstaged diff가 비어 있음을 확인한다. + +수정 파일 및 체크리스트: +- [ ] `services/core/internal/gitengine/command_test.go`: status/diff integration test를 추가한다. +- [ ] `services/core/internal/gitengine/command_test.go`: diff assertion helper는 test-local helper로만 둔다. + +테스트 작성: 작성한다. Test name은 `TestStatusDiffWithCleanStagedUnstagedWorkspace`; assertion은 clean status, dirty status, unstaged diff, staged diff를 각각 확인한다. + +중간 검증: +```bash +cd services/core && go test -count=1 ./internal/gitengine -run TestStatusDiffWithCleanStagedUnstagedWorkspace +``` +기대 결과: `ok git.toki-labs.com/toki/gito/services/core/internal/gitengine`. + +### [OPS-3] Commit/Push Remote Revision + +문제: `Commit`/`Push`는 `services/core/internal/gitengine/command.go:126`과 `:139`에 wrapper가 있지만, `services/core/internal/gitengine/command_test.go:118`의 fake runner test만 있다. 실제 local bare remote branch revision 반영을 검증하지 않는다. + +해결 방법: `TestCommitPushToLocalBareRemote`를 추가한다. helper로 clone한 worktree에서 local user config를 설정하고 파일을 수정한다. `Commit(CLI{}, worktree, CommitOptions{Message:"...", All:true})` 후 `Push(CLI{}, worktree, "", "main")`를 실행한다. `HeadRevision(CLI{}, worktree)`와 bare remote `main` revision이 같은지 확인한다. + +수정 파일 및 체크리스트: +- [ ] `services/core/internal/gitengine/command_test.go`: `TestCommitPushToLocalBareRemote`를 추가한다. +- [ ] `services/core/internal/gitengine/command_test.go`: test helper가 local git user config를 항상 설정하게 한다. + +테스트 작성: 작성한다. Test name은 `TestCommitPushToLocalBareRemote`; assertion은 worktree HEAD와 bare remote branch revision equality다. + +중간 검증: +```bash +cd services/core && go test -count=1 ./internal/gitengine -run TestCommitPushToLocalBareRemote +``` +기대 결과: `ok git.toki-labs.com/toki/gito/services/core/internal/gitengine`. + +### [OPS-4] Source Branch Checkout API + +문제: `Checkout`은 `services/core/internal/gitengine/command.go:82`에서 `git checkout `만 실행한다. 마일스톤 task의 "source branch 기준"을 표현할 input이 없어 `feature` branch를 `main` 또는 다른 revision에서 생성/초기화할 수 없다. + +해결 방법: 기존 `Checkout`은 호환 wrapper로 유지하고, `CheckoutOptions`와 `CheckoutBranch`를 추가한다. `StartPoint`가 비어 있으면 기존 checkout과 동일하게 `git checkout `를 호출한다. `StartPoint`가 있으면 기본 `git checkout -b `를 호출하고, `Reset`이 true면 `git checkout -B `를 호출한다. + +Before: +```go +// services/core/internal/gitengine/command.go:82 +func Checkout(runner CommandRunner, workdir, branch string) error { + if strings.TrimSpace(workdir) == "" || strings.TrimSpace(branch) == "" { + return ErrInvalidGitInput + } + _, err := runner.Run(workdir, "checkout", branch) + return err +} +``` + +After: +```go +type CheckoutOptions struct { + Branch string + StartPoint string + Reset bool +} + +func Checkout(runner CommandRunner, workdir, branch string) error { + return CheckoutBranch(runner, workdir, CheckoutOptions{Branch: branch}) +} + +func CheckoutBranch(runner CommandRunner, workdir string, opts CheckoutOptions) error { + workdir = strings.TrimSpace(workdir) + branch := strings.TrimSpace(opts.Branch) + if workdir == "" || branch == "" { + return ErrInvalidGitInput + } + startPoint := strings.TrimSpace(opts.StartPoint) + if startPoint == "" { + _, err := runner.Run(workdir, "checkout", branch) + return err + } + flag := "-b" + if opts.Reset { + flag = "-B" + } + _, err := runner.Run(workdir, "checkout", flag, branch, startPoint) + return err +} +``` + +수정 파일 및 체크리스트: +- [ ] `services/core/internal/gitengine/command.go`: `CheckoutOptions`와 `CheckoutBranch`를 추가하고 기존 `Checkout`을 delegating wrapper로 유지한다. +- [ ] `services/core/internal/gitengine/command_test.go`: fake runner 단위 테스트에서 `CheckoutBranch` 인자를 검증한다. +- [ ] `services/core/internal/gitengine/command_test.go`: `TestCheckoutBranchFromSource` integration test를 추가한다. + +테스트 작성: 작성한다. Test names는 `TestCheckoutBranchFromSourceArgs`와 `TestCheckoutBranchFromSource`; assertion은 source branch/start point revision에서 새 branch가 만들어지고 worktree HEAD가 source revision과 같음을 확인한다. + +중간 검증: +```bash +cd services/core && go test -count=1 ./internal/gitengine -run 'TestCheckoutBranchFromSource|TestCloneFetchCheckout' +``` +기대 결과: `ok git.toki-labs.com/toki/gito/services/core/internal/gitengine`. + +## 수정 파일 요약 + +| 파일 | 항목 | +| --- | --- | +| `services/core/internal/gitengine/command.go` | OPS-4 | +| `services/core/internal/gitengine/command_test.go` | OPS-1, OPS-2, OPS-3, OPS-4 | + +## 최종 검증 + +```bash +cd services/core && go test -count=1 ./internal/gitengine +``` + +```bash +cd services/core && go test ./... +``` + +```bash +git diff --check +``` + +기대 결과: 모든 명령이 exit code 0으로 종료한다. `go test ./...`는 Go cache output을 허용한다. `go test -count=1 ./internal/gitengine`은 gitengine integration test를 fresh execution으로 검증한다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/packages/contracts/notes/gito-control-plane.md b/packages/contracts/notes/gito-control-plane.md index 561660a..52daa54 100644 --- a/packages/contracts/notes/gito-control-plane.md +++ b/packages/contracts/notes/gito-control-plane.md @@ -35,6 +35,13 @@ own Milestone wires storage, worker, or provider behavior. | `agent_shell` | `heartbeat`, `dispatch`, `stream_logs` | | `event` | `subscribe`, `list`, `ack` | +## REST Bootstrap Exceptions + +REST may expose temporary repo bootstrap helpers for smoke/curl setup while the +proto-socket `repo` channel is still placeholder-only. Those helpers must stay +limited to bootstrap registration, lookup, and listing, and must use the same +Repo DTO fields documented below. + ## MVP: Forgejo Branch Event Flow Forgejo branch event delivery is now a provided contract: diff --git a/services/core/internal/gitengine/command.go b/services/core/internal/gitengine/command.go index 2151661..7433504 100644 --- a/services/core/internal/gitengine/command.go +++ b/services/core/internal/gitengine/command.go @@ -42,6 +42,11 @@ type CommitOptions struct { All bool } +type DiffOptions struct { + Staged bool + Pathspecs []string +} + type ChangedFile struct { Path string ChangeType string @@ -74,11 +79,32 @@ func Fetch(runner CommandRunner, workdir, remote string) error { return err } +type CheckoutOptions struct { + Branch string + StartPoint string + Reset bool +} + func Checkout(runner CommandRunner, workdir, branch string) error { - if strings.TrimSpace(workdir) == "" || strings.TrimSpace(branch) == "" { + return CheckoutBranch(runner, workdir, CheckoutOptions{Branch: branch}) +} + +func CheckoutBranch(runner CommandRunner, workdir string, opts CheckoutOptions) error { + workdir = strings.TrimSpace(workdir) + branch := strings.TrimSpace(opts.Branch) + if workdir == "" || branch == "" { return ErrInvalidGitInput } - _, err := runner.Run(workdir, "checkout", branch) + startPoint := strings.TrimSpace(opts.StartPoint) + if startPoint == "" { + _, err := runner.Run(workdir, "checkout", branch) + return err + } + flag := "-b" + if opts.Reset { + flag = "-B" + } + _, err := runner.Run(workdir, "checkout", flag, branch, startPoint) return err } @@ -96,6 +122,28 @@ func Status(runner CommandRunner, workdir string) (StatusResult, error) { }, nil } +func Diff(runner CommandRunner, workdir string, opts DiffOptions) (string, error) { + if strings.TrimSpace(workdir) == "" { + return "", ErrInvalidGitInput + } + args := []string{"diff"} + if opts.Staged { + args = append(args, "--cached") + } + pathspecs := make([]string, 0, len(opts.Pathspecs)) + for _, pathspec := range opts.Pathspecs { + pathspec = strings.TrimSpace(pathspec) + if pathspec != "" { + pathspecs = append(pathspecs, pathspec) + } + } + if len(pathspecs) > 0 { + args = append(args, "--") + args = append(args, pathspecs...) + } + return runner.Run(workdir, args...) +} + func Commit(runner CommandRunner, workdir string, opts CommitOptions) error { if strings.TrimSpace(workdir) == "" || strings.TrimSpace(opts.Message) == "" { return ErrInvalidGitInput diff --git a/services/core/internal/gitengine/command_test.go b/services/core/internal/gitengine/command_test.go index fb1904c..9f777c1 100644 --- a/services/core/internal/gitengine/command_test.go +++ b/services/core/internal/gitengine/command_test.go @@ -2,7 +2,11 @@ package gitengine import ( "errors" + "os" + "os/exec" + "path/filepath" "reflect" + "strings" "testing" ) @@ -48,6 +52,47 @@ func TestStatusClean(t *testing.T) { } } +func TestDiff(t *testing.T) { + runner := &fakeRunner{outputs: []string{"diff output"}} + got, err := Diff(runner, "/repo", DiffOptions{}) + if err != nil { + t.Fatalf("Diff: %v", err) + } + if got != "diff output" { + t.Fatalf("diff output: got %q", got) + } + want := []fakeCall{ + {workdir: "/repo", args: []string{"diff"}}, + } + if !reflect.DeepEqual(runner.calls, want) { + t.Fatalf("calls:\n got %#v\nwant %#v", runner.calls, want) + } +} + +func TestDiffStagedPathspecs(t *testing.T) { + runner := &fakeRunner{} + _, err := Diff(runner, "/repo", DiffOptions{ + Staged: true, + Pathspecs: []string{" README.md ", "", "services/core/main.go"}, + }) + if err != nil { + t.Fatalf("Diff: %v", err) + } + want := []fakeCall{ + {workdir: "/repo", args: []string{"diff", "--cached", "--", "README.md", "services/core/main.go"}}, + } + if !reflect.DeepEqual(runner.calls, want) { + t.Fatalf("calls:\n got %#v\nwant %#v", runner.calls, want) + } +} + +func TestDiffRequiresWorkdir(t *testing.T) { + _, err := Diff(&fakeRunner{}, "", DiffOptions{}) + if !errors.Is(err, ErrInvalidGitInput) { + t.Fatalf("expected ErrInvalidGitInput, got %v", err) + } +} + func TestCloneFetchCheckout(t *testing.T) { runner := &fakeRunner{} if err := Clone(runner, CloneOptions{ @@ -138,3 +183,344 @@ func TestChangedFilesWithStatus(t *testing.T) { t.Fatalf("files:\n got %#v\nwant %#v", files, want) } } + +func runGit(t *testing.T, dir string, args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s failed in %s: %v\nOutput: %s", strings.Join(args, " "), dir, err, string(out)) + } +} + +func gitOutput(t *testing.T, dir string, args ...string) string { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s failed in %s: %v\nOutput: %s", strings.Join(args, " "), dir, err, string(out)) + } + return strings.TrimSpace(string(out)) +} + +func writeFile(t *testing.T, path, content string) { + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("WriteFile %s failed: %v", path, err) + } +} + +func setupBareRemote(t *testing.T) (barePath, seedPath string, initialRev string) { + tmpDir := t.TempDir() + + // 1. Create bare remote repo + barePath = filepath.Join(tmpDir, "remote.git") + if err := os.MkdirAll(barePath, 0755); err != nil { + t.Fatalf("failed to create bare remote dir: %v", err) + } + runGit(t, barePath, "init", "--bare") + // Make sure default branch is main + runGit(t, barePath, "symbolic-ref", "HEAD", "refs/heads/main") + + // 2. Create seed repo to initialize the remote + seedPath = filepath.Join(tmpDir, "seed") + if err := os.MkdirAll(seedPath, 0755); err != nil { + t.Fatalf("failed to create seed dir: %v", err) + } + runGit(t, seedPath, "init") + runGit(t, seedPath, "config", "user.name", "Test User") + runGit(t, seedPath, "config", "user.email", "test@example.com") + runGit(t, seedPath, "config", "commit.gpgsign", "false") + + // Create first commit + writeFile(t, filepath.Join(seedPath, "README.md"), "# Seed Repository\n") + runGit(t, seedPath, "add", "README.md") + runGit(t, seedPath, "commit", "-m", "initial commit") + runGit(t, seedPath, "branch", "-M", "main") + + // Push to bare remote + runGit(t, seedPath, "remote", "add", "origin", barePath) + runGit(t, seedPath, "push", "-u", "origin", "main") + + initialRev = gitOutput(t, seedPath, "rev-parse", "HEAD") + return barePath, seedPath, initialRev +} + +func TestCloneFetchWithLocalBareRemote(t *testing.T) { + barePath, seedPath, initialRev := setupBareRemote(t) + tmpDir := t.TempDir() + worktreePath := filepath.Join(tmpDir, "worktree") + + // 1. Clone + cli := CLI{} + err := Clone(cli, CloneOptions{ + RemoteURL: barePath, + WorktreePath: worktreePath, + Branch: "main", + }) + if err != nil { + t.Fatalf("Clone failed: %v", err) + } + + // Verify cloned commit + rev, err := HeadRevision(cli, worktreePath) + if err != nil { + t.Fatalf("HeadRevision failed: %v", err) + } + if rev != initialRev { + t.Fatalf("expected cloned rev %q, got %q", initialRev, rev) + } + + // 2. Make new commit in seed and push to bare remote + writeFile(t, filepath.Join(seedPath, "file.txt"), "hello") + runGit(t, seedPath, "add", "file.txt") + runGit(t, seedPath, "commit", "-m", "second commit") + runGit(t, seedPath, "push", "origin", "main") + newRev := gitOutput(t, seedPath, "rev-parse", "HEAD") + + // 3. Fetch in worktree + err = Fetch(cli, worktreePath, "origin") + if err != nil { + t.Fatalf("Fetch failed: %v", err) + } + + // Verify origin/main has the new commit + fetchedRev := gitOutput(t, worktreePath, "rev-parse", "origin/main") + if fetchedRev != newRev { + t.Fatalf("expected fetched rev %q, got %q", newRev, fetchedRev) + } +} + +func TestStatusDiffWithCleanStagedUnstagedWorkspace(t *testing.T) { + barePath, _, _ := setupBareRemote(t) + tmpDir := t.TempDir() + worktreePath := filepath.Join(tmpDir, "worktree") + + cli := CLI{} + if err := Clone(cli, CloneOptions{RemoteURL: barePath, WorktreePath: worktreePath, Branch: "main"}); err != nil { + t.Fatalf("Clone failed: %v", err) + } + + // 1. Clean workspace + status, err := Status(cli, worktreePath) + if err != nil { + t.Fatalf("Status failed: %v", err) + } + if !status.Clean { + t.Fatalf("expected clean status, got porcelain: %q", status.Output) + } + + diff, err := Diff(cli, worktreePath, DiffOptions{}) + if err != nil { + t.Fatalf("Diff failed: %v", err) + } + if diff != "" { + t.Fatalf("expected empty unstaged diff, got: %q", diff) + } + + // 2. Unstaged changes (modified file) + writeFile(t, filepath.Join(worktreePath, "README.md"), "# Seed Repository\nmodified content\n") + + status, err = Status(cli, worktreePath) + if err != nil { + t.Fatalf("Status failed: %v", err) + } + if status.Clean { + t.Fatal("expected status to be dirty (unstaged changes)") + } + if !strings.Contains(status.Output, " README.md") { + t.Fatalf("expected README.md in status, got: %q", status.Output) + } + + diff, err = Diff(cli, worktreePath, DiffOptions{}) + if err != nil { + t.Fatalf("Diff failed: %v", err) + } + if !strings.Contains(diff, "+modified content") { + t.Fatalf("expected diff to show modifications, got: %q", diff) + } + + // Staged diff should be empty + stagedDiff, err := Diff(cli, worktreePath, DiffOptions{Staged: true}) + if err != nil { + t.Fatalf("Diff staged failed: %v", err) + } + if stagedDiff != "" { + t.Fatalf("expected empty staged diff, got: %q", stagedDiff) + } + + // 3. Staged changes + runGit(t, worktreePath, "add", "README.md") + + status, err = Status(cli, worktreePath) + if err != nil { + t.Fatalf("Status failed: %v", err) + } + if status.Clean { + t.Fatal("expected status to be dirty (staged changes)") + } + + // Unstaged diff should be empty now + diff, err = Diff(cli, worktreePath, DiffOptions{}) + if err != nil { + t.Fatalf("Diff failed: %v", err) + } + if diff != "" { + t.Fatalf("expected empty unstaged diff after staging, got: %q", diff) + } + + // Staged diff should show modifications + stagedDiff, err = Diff(cli, worktreePath, DiffOptions{Staged: true}) + if err != nil { + t.Fatalf("Diff staged failed: %v", err) + } + if !strings.Contains(stagedDiff, "+modified content") { + t.Fatalf("expected staged diff to show modifications, got: %q", stagedDiff) + } +} + +func TestCommitPushToLocalBareRemote(t *testing.T) { + barePath, _, _ := setupBareRemote(t) + tmpDir := t.TempDir() + worktreePath := filepath.Join(tmpDir, "worktree") + + cli := CLI{} + if err := Clone(cli, CloneOptions{RemoteURL: barePath, WorktreePath: worktreePath, Branch: "main"}); err != nil { + t.Fatalf("Clone failed: %v", err) + } + + // Configure local git user for the worktree as well + runGit(t, worktreePath, "config", "user.name", "Test User") + runGit(t, worktreePath, "config", "user.email", "test@example.com") + runGit(t, worktreePath, "config", "commit.gpgsign", "false") + + // Make changes and commit + writeFile(t, filepath.Join(worktreePath, "newfile.txt"), "hello commit") + err := Commit(cli, worktreePath, CommitOptions{ + Message: "add newfile", + All: true, + }) + if err != nil { + t.Fatalf("Commit failed: %v", err) + } + + localRev, err := HeadRevision(cli, worktreePath) + if err != nil { + t.Fatalf("HeadRevision failed: %v", err) + } + + // Push + err = Push(cli, worktreePath, "origin", "main") + if err != nil { + t.Fatalf("Push failed: %v", err) + } + + // Verify bare remote branch has updated to local commit revision + remoteRev := gitOutput(t, barePath, "rev-parse", "main") + if remoteRev != localRev { + t.Fatalf("expected remote revision %q, got %q", localRev, remoteRev) + } +} + +func TestCheckoutBranchFromSourceArgs(t *testing.T) { + runner := &fakeRunner{} + + // 1. Simple checkout + if err := CheckoutBranch(runner, "/repo", CheckoutOptions{Branch: "main"}); err != nil { + t.Fatalf("CheckoutBranch failed: %v", err) + } + + // 2. Checkout with start point + if err := CheckoutBranch(runner, "/repo", CheckoutOptions{Branch: "feature/a", StartPoint: "main"}); err != nil { + t.Fatalf("CheckoutBranch failed: %v", err) + } + + // 3. Checkout reset with start point + if err := CheckoutBranch(runner, "/repo", CheckoutOptions{Branch: "feature/b", StartPoint: "main", Reset: true}); err != nil { + t.Fatalf("CheckoutBranch failed: %v", err) + } + + want := []fakeCall{ + {workdir: "/repo", args: []string{"checkout", "main"}}, + {workdir: "/repo", args: []string{"checkout", "-b", "feature/a", "main"}}, + {workdir: "/repo", args: []string{"checkout", "-B", "feature/b", "main"}}, + } + if !reflect.DeepEqual(runner.calls, want) { + t.Fatalf("calls:\n got %#v\nwant %#v", runner.calls, want) + } +} + +func TestCheckoutBranchFromSource(t *testing.T) { + barePath, _, initialRev := setupBareRemote(t) + tmpDir := t.TempDir() + worktreePath := filepath.Join(tmpDir, "worktree") + + cli := CLI{} + if err := Clone(cli, CloneOptions{RemoteURL: barePath, WorktreePath: worktreePath, Branch: "main"}); err != nil { + t.Fatalf("Clone failed: %v", err) + } + + // Configure local user + runGit(t, worktreePath, "config", "user.name", "Test User") + runGit(t, worktreePath, "config", "user.email", "test@example.com") + runGit(t, worktreePath, "config", "commit.gpgsign", "false") + + // Make another commit in main + writeFile(t, filepath.Join(worktreePath, "main_only.txt"), "main data") + if err := Commit(cli, worktreePath, CommitOptions{Message: "commit on main", All: true}); err != nil { + t.Fatalf("Commit failed: %v", err) + } + mainRev, err := HeadRevision(cli, worktreePath) + if err != nil { + t.Fatalf("HeadRevision failed: %v", err) + } + + // 1. Checkout new branch from main's initialRev (start point) + err = CheckoutBranch(cli, worktreePath, CheckoutOptions{ + Branch: "feature/from-start", + StartPoint: initialRev, + }) + if err != nil { + t.Fatalf("CheckoutBranch from initialRev failed: %v", err) + } + + // Verify we are on feature/from-start, and revision is initialRev (not mainRev) + currentRev, err := HeadRevision(cli, worktreePath) + if err != nil { + t.Fatalf("HeadRevision failed: %v", err) + } + if currentRev != initialRev { + t.Fatalf("expected HEAD revision to be %q, got %q", initialRev, currentRev) + } + + // 2. Checkout new branch from main's current mainRev (start point) with Reset: true + // To test Reset flag (-B), checkout main again + if err := Checkout(cli, worktreePath, "main"); err != nil { + t.Fatalf("Checkout main failed: %v", err) + } + + // Create branch feature/reset with initialRev first + err = CheckoutBranch(cli, worktreePath, CheckoutOptions{ + Branch: "feature/reset", + StartPoint: initialRev, + }) + if err != nil { + t.Fatalf("CheckoutBranch feature/reset failed: %v", err) + } + + // Now reset it to mainRev + err = CheckoutBranch(cli, worktreePath, CheckoutOptions{ + Branch: "feature/reset", + StartPoint: mainRev, + Reset: true, + }) + if err != nil { + t.Fatalf("CheckoutBranch reset failed: %v", err) + } + + currentRev, err = HeadRevision(cli, worktreePath) + if err != nil { + t.Fatalf("HeadRevision failed: %v", err) + } + if currentRev != mainRev { + t.Fatalf("expected HEAD revision to be reset to %q, got %q", mainRev, currentRev) + } +} diff --git a/services/core/internal/storage/postgres.go b/services/core/internal/storage/postgres.go index 17eb6f1..318c7b3 100644 --- a/services/core/internal/storage/postgres.go +++ b/services/core/internal/storage/postgres.go @@ -2,10 +2,14 @@ package storage import ( "context" + "database/sql" + "errors" "fmt" "strings" "time" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "git.toki-labs.com/toki/gito/services/core/internal/core" @@ -14,6 +18,8 @@ import ( // PgStore is a pgxpool-backed Store implementation. type PgStore struct { pool *pgxpool.Pool + repos *pgRepoStore + leases *pgWorkspaceLeaseStore watches *pgBranchWatchStore cursors *pgRevisionCursorStore deliveries *pgProviderDeliveryStore @@ -38,6 +44,8 @@ func NewPgStore(ctx context.Context, dsn string, migrationSQL string) (*PgStore, } } s := &PgStore{pool: pool} + s.repos = &pgRepoStore{pool: pool} + s.leases = &pgWorkspaceLeaseStore{pool: pool} s.watches = &pgBranchWatchStore{pool: pool} s.cursors = &pgRevisionCursorStore{pool: pool} s.deliveries = &pgProviderDeliveryStore{pool: pool} @@ -48,12 +56,12 @@ func (s *PgStore) Close() { s.pool.Close() } func (s *PgStore) Ping(ctx context.Context) error { return s.pool.Ping(ctx) } -func (s *PgStore) Repos() RepoStore { return nil } -func (s *PgStore) WorkspaceLeases() WorkspaceLeaseStore { return nil } -func (s *PgStore) Operations() OperationStore { return nil } -func (s *PgStore) OperationEvents() OperationEventStore { return nil } -func (s *PgStore) BranchWatches() BranchWatchStore { return s.watches } -func (s *PgStore) RevisionCursors() RevisionCursorStore { return s.cursors } +func (s *PgStore) Repos() RepoStore { return s.repos } +func (s *PgStore) WorkspaceLeases() WorkspaceLeaseStore { return s.leases } +func (s *PgStore) Operations() OperationStore { return nil } +func (s *PgStore) OperationEvents() OperationEventStore { return nil } +func (s *PgStore) BranchWatches() BranchWatchStore { return s.watches } +func (s *PgStore) RevisionCursors() RevisionCursorStore { return s.cursors } func (s *PgStore) ProviderDeliveries() ProviderDeliveryStore { return s.deliveries } // applyMigrationSQL executes the Up block extracted from goose-style SQL content. @@ -84,6 +92,411 @@ func extractUpBlock(content string) string { return strings.TrimSpace(after[beginIdx+len(beginMarker) : endIdx]) } +// pgRepoStore + +type pgRepoStore struct{ pool *pgxpool.Pool } + +func (s *pgRepoStore) CreateRepo(ctx context.Context, repo core.Repo) error { + repo, err := normalizeRepo(repo) + if err != nil { + return err + } + + const q = ` + INSERT INTO repos (id, name, remote_url, default_branch, workspace_root, credential_ref) + VALUES ($1, $2, $3, $4, $5, $6) + ` + _, err = s.pool.Exec(ctx, q, + repo.ID, + repo.Name, + repo.RemoteURL, + repo.DefaultBranch, + repo.WorkspaceRoot, + nullableString(repo.CredentialRef), + ) + if err != nil { + if isUniqueViolation(err) { + return fmt.Errorf("%w: %s", ErrRepoAlreadyExists, repo.ID) + } + return fmt.Errorf("create repo: %w", err) + } + return nil +} + +func (s *pgRepoStore) GetRepo(ctx context.Context, id string) (core.Repo, error) { + id = strings.TrimSpace(id) + if id == "" { + return core.Repo{}, fmt.Errorf("%w: id is required", ErrInvalidRepo) + } + + const q = ` + SELECT id, name, remote_url, default_branch, workspace_root, credential_ref + FROM repos + WHERE id=$1 + ` + repo, err := scanRepo(s.pool.QueryRow(ctx, q, id)) + if err != nil { + if isNoRows(err) { + return core.Repo{}, fmt.Errorf("%w: %s", ErrRepoNotFound, id) + } + return core.Repo{}, fmt.Errorf("get repo: %w", err) + } + return repo, nil +} + +func (s *pgRepoStore) ListRepos(ctx context.Context) ([]core.Repo, error) { + const q = ` + SELECT id, name, remote_url, default_branch, workspace_root, credential_ref + FROM repos + ORDER BY id + ` + rows, err := s.pool.Query(ctx, q) + if err != nil { + return nil, fmt.Errorf("list repos: %w", err) + } + defer rows.Close() + + var out []core.Repo + for rows.Next() { + repo, err := scanRepo(rows) + if err != nil { + return nil, fmt.Errorf("scan repo: %w", err) + } + out = append(out, repo) + } + return out, rows.Err() +} + +func (s *pgRepoStore) UpdateRepo(ctx context.Context, repo core.Repo) error { + repo, err := normalizeRepo(repo) + if err != nil { + return err + } + + const q = ` + UPDATE repos + SET name=$2, + remote_url=$3, + default_branch=$4, + workspace_root=$5, + credential_ref=$6, + updated_at=now() + WHERE id=$1 + ` + tag, err := s.pool.Exec(ctx, q, + repo.ID, + repo.Name, + repo.RemoteURL, + repo.DefaultBranch, + repo.WorkspaceRoot, + nullableString(repo.CredentialRef), + ) + if err != nil { + return fmt.Errorf("update repo: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("%w: %s", ErrRepoNotFound, repo.ID) + } + return nil +} + +func normalizeRepo(repo core.Repo) (core.Repo, error) { + repo.ID = strings.TrimSpace(repo.ID) + repo.Name = strings.TrimSpace(repo.Name) + repo.RemoteURL = strings.TrimSpace(repo.RemoteURL) + repo.DefaultBranch = strings.TrimSpace(repo.DefaultBranch) + repo.WorkspaceRoot = strings.TrimSpace(repo.WorkspaceRoot) + repo.CredentialRef = strings.TrimSpace(repo.CredentialRef) + + var missing []string + if repo.ID == "" { + missing = append(missing, "id") + } + if repo.Name == "" { + missing = append(missing, "name") + } + if repo.RemoteURL == "" { + missing = append(missing, "remote_url") + } + if repo.DefaultBranch == "" { + missing = append(missing, "default_branch") + } + if repo.WorkspaceRoot == "" { + missing = append(missing, "workspace_root") + } + if len(missing) > 0 { + return core.Repo{}, fmt.Errorf("%w: required fields missing: %s", ErrInvalidRepo, strings.Join(missing, ", ")) + } + return repo, nil +} + +type repoScanner interface { + Scan(dest ...any) error +} + +func scanRepo(scanner repoScanner) (core.Repo, error) { + var repo core.Repo + var credentialRef sql.NullString + if err := scanner.Scan( + &repo.ID, + &repo.Name, + &repo.RemoteURL, + &repo.DefaultBranch, + &repo.WorkspaceRoot, + &credentialRef, + ); err != nil { + return core.Repo{}, err + } + if credentialRef.Valid { + repo.CredentialRef = credentialRef.String + } + return repo, nil +} + +func nullableString(value string) any { + if value == "" { + return nil + } + return value +} + +// pgWorkspaceLeaseStore + +type pgWorkspaceLeaseStore struct{ pool *pgxpool.Pool } + +func (s *pgWorkspaceLeaseStore) SaveLease(ctx context.Context, lease core.WorkspaceLease) error { + lease, err := normalizeLease(lease) + if err != nil { + return err + } + + const q = ` + INSERT INTO workspace_leases (id, repo_id, slot, path, state, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + ` + _, err = s.pool.Exec(ctx, q, + lease.ID, + lease.RepoID, + lease.Slot, + lease.Path, + lease.State, + nullableTime(lease.ExpiresAt), + ) + if err != nil { + if isUniqueViolation(err) { + return fmt.Errorf("%w: %s", ErrLeaseAlreadyExists, lease.ID) + } + return fmt.Errorf("save lease: %w", err) + } + return nil +} + +func (s *pgWorkspaceLeaseStore) GetLease(ctx context.Context, id string) (core.WorkspaceLease, error) { + id = strings.TrimSpace(id) + if id == "" { + return core.WorkspaceLease{}, fmt.Errorf("%w: id is required", ErrInvalidLease) + } + + const q = ` + SELECT id, repo_id, slot, path, state, expires_at + FROM workspace_leases + WHERE id=$1 + ` + lease, err := scanLease(s.pool.QueryRow(ctx, q, id)) + if err != nil { + if isNoRows(err) { + return core.WorkspaceLease{}, fmt.Errorf("%w: %s", ErrLeaseNotFound, id) + } + return core.WorkspaceLease{}, fmt.Errorf("get lease: %w", err) + } + return lease, nil +} + +func (s *pgWorkspaceLeaseStore) ListLeases(ctx context.Context, repoID string) ([]core.WorkspaceLease, error) { + repoID = strings.TrimSpace(repoID) + if repoID == "" { + return nil, fmt.Errorf("%w: repo_id is required", ErrInvalidLease) + } + + const q = ` + SELECT id, repo_id, slot, path, state, expires_at + FROM workspace_leases + WHERE repo_id=$1 + ORDER BY slot + ` + rows, err := s.pool.Query(ctx, q, repoID) + if err != nil { + return nil, fmt.Errorf("list leases: %w", err) + } + defer rows.Close() + + var out []core.WorkspaceLease + for rows.Next() { + lease, err := scanLease(rows) + if err != nil { + return nil, fmt.Errorf("scan lease: %w", err) + } + out = append(out, lease) + } + return out, rows.Err() +} + +func (s *pgWorkspaceLeaseStore) AcquireAvailableLease(ctx context.Context, repoID string, now time.Time, ttl time.Duration) (core.WorkspaceLease, bool, error) { + repoID = strings.TrimSpace(repoID) + if repoID == "" { + return core.WorkspaceLease{}, false, fmt.Errorf("%w: repo_id is required", ErrInvalidLease) + } + if ttl <= 0 { + return core.WorkspaceLease{}, false, fmt.Errorf("%w: ttl must be positive", ErrInvalidLease) + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return core.WorkspaceLease{}, false, fmt.Errorf("begin lease acquire: %w", err) + } + defer tx.Rollback(ctx) + + expiresAt := now.UTC().Add(ttl) + const q = ` + WITH candidate AS ( + SELECT id + FROM workspace_leases + WHERE repo_id=$1 AND state=$2 + ORDER BY slot + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE workspace_leases AS lease + SET state=$3, + expires_at=$4, + updated_at=now() + FROM candidate + WHERE lease.id=candidate.id + RETURNING lease.id, lease.repo_id, lease.slot, lease.path, lease.state, lease.expires_at + ` + lease, err := scanLease(tx.QueryRow(ctx, q, repoID, core.WorkspaceAvailable, core.WorkspaceLeased, expiresAt)) + if err != nil { + if isNoRows(err) { + return core.WorkspaceLease{}, false, nil + } + return core.WorkspaceLease{}, false, fmt.Errorf("acquire lease: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return core.WorkspaceLease{}, false, fmt.Errorf("commit lease acquire: %w", err) + } + return lease, true, nil +} + +func (s *pgWorkspaceLeaseStore) ReleaseLease(ctx context.Context, id string, release LeaseRelease) (core.WorkspaceLease, error) { + id = strings.TrimSpace(id) + if id == "" { + return core.WorkspaceLease{}, fmt.Errorf("%w: id is required", ErrInvalidLease) + } + nextState, err := leaseStateForRelease(release) + if err != nil { + return core.WorkspaceLease{}, err + } + + const q = ` + UPDATE workspace_leases + SET state=$2, + expires_at=NULL, + updated_at=now() + WHERE id=$1 AND state=$3 + RETURNING id, repo_id, slot, path, state, expires_at + ` + lease, err := scanLease(s.pool.QueryRow(ctx, q, id, nextState, core.WorkspaceLeased)) + if err != nil { + if isNoRows(err) { + existing, getErr := s.GetLease(ctx, id) + if getErr != nil { + return core.WorkspaceLease{}, getErr + } + return core.WorkspaceLease{}, fmt.Errorf("%w: lease %s is %s, not %s", ErrInvalidLease, existing.ID, existing.State, core.WorkspaceLeased) + } + return core.WorkspaceLease{}, fmt.Errorf("release lease: %w", err) + } + return lease, nil +} + +func normalizeLease(lease core.WorkspaceLease) (core.WorkspaceLease, error) { + lease.ID = strings.TrimSpace(lease.ID) + lease.RepoID = strings.TrimSpace(lease.RepoID) + lease.Slot = strings.TrimSpace(lease.Slot) + lease.Path = strings.TrimSpace(lease.Path) + + var missing []string + if lease.ID == "" { + missing = append(missing, "id") + } + if lease.RepoID == "" { + missing = append(missing, "repo_id") + } + if lease.Slot == "" { + missing = append(missing, "slot") + } + if lease.Path == "" { + missing = append(missing, "path") + } + if len(missing) > 0 { + return core.WorkspaceLease{}, fmt.Errorf("%w: required fields missing: %s", ErrInvalidLease, strings.Join(missing, ", ")) + } + if !isValidWorkspaceState(lease.State) { + return core.WorkspaceLease{}, fmt.Errorf("%w: unsupported state %q", ErrInvalidLease, lease.State) + } + return lease, nil +} + +func isValidWorkspaceState(state core.WorkspaceState) bool { + switch state { + case core.WorkspaceAvailable, core.WorkspaceLeased, core.WorkspaceDirty, core.WorkspaceError: + return true + default: + return false + } +} + +func leaseStateForRelease(release LeaseRelease) (core.WorkspaceState, error) { + if release.Dirty { + return core.WorkspaceDirty, nil + } + switch release.OperationState { + case core.OperationSucceeded, core.OperationCancelled: + return core.WorkspaceAvailable, nil + case core.OperationFailed: + return core.WorkspaceError, nil + default: + return "", fmt.Errorf("%w: unsupported release operation state %q", ErrInvalidLease, release.OperationState) + } +} + +func scanLease(scanner repoScanner) (core.WorkspaceLease, error) { + var lease core.WorkspaceLease + var expiresAt sql.NullTime + if err := scanner.Scan( + &lease.ID, + &lease.RepoID, + &lease.Slot, + &lease.Path, + &lease.State, + &expiresAt, + ); err != nil { + return core.WorkspaceLease{}, err + } + if expiresAt.Valid { + value := expiresAt.Time + lease.ExpiresAt = &value + } + return lease, nil +} + +func nullableTime(value *time.Time) any { + if value == nil { + return nil + } + return value.UTC() +} + // pgBranchWatchStore type pgBranchWatchStore struct{ pool *pgxpool.Pool } @@ -204,5 +617,10 @@ func (s *pgProviderDeliveryStore) RecordOnce(ctx context.Context, delivery core. } func isNoRows(err error) bool { - return err != nil && strings.Contains(err.Error(), "no rows") + return err != nil && (errors.Is(err, pgx.ErrNoRows) || strings.Contains(err.Error(), "no rows")) +} + +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" } diff --git a/services/core/internal/storage/postgres_repo_test.go b/services/core/internal/storage/postgres_repo_test.go new file mode 100644 index 0000000..f788bd5 --- /dev/null +++ b/services/core/internal/storage/postgres_repo_test.go @@ -0,0 +1,115 @@ +package storage + +import ( + "context" + "errors" + "testing" + "time" + + "git.toki-labs.com/toki/gito/services/core/internal/core" +) + +func TestPgRepoStoreValidatesRequiredFieldsBeforeDatabase(t *testing.T) { + store := &pgRepoStore{} + err := store.CreateRepo(context.Background(), core.Repo{ + ID: "repo-missing-remote", + Name: "Missing Remote", + DefaultBranch: "main", + WorkspaceRoot: "/workspaces/missing-remote", + }) + if !errors.Is(err, ErrInvalidRepo) { + t.Fatalf("CreateRepo missing remote validation: got %v want %v", err, ErrInvalidRepo) + } + + err = store.UpdateRepo(context.Background(), core.Repo{ + ID: "repo-missing-name", + RemoteURL: "ssh://git.example.invalid/toki/repo.git", + DefaultBranch: "main", + WorkspaceRoot: "/workspaces/repo", + }) + if !errors.Is(err, ErrInvalidRepo) { + t.Fatalf("UpdateRepo missing name validation: got %v want %v", err, ErrInvalidRepo) + } +} + +func TestPgWorkspaceLeaseStoreValidatesBeforeDatabase(t *testing.T) { + store := &pgWorkspaceLeaseStore{} + err := store.SaveLease(context.Background(), core.WorkspaceLease{ + ID: "lease-missing-state", + RepoID: "repo", + Slot: "001", + Path: "/workspaces/repo/001", + }) + if !errors.Is(err, ErrInvalidLease) { + t.Fatalf("SaveLease missing state validation: got %v want %v", err, ErrInvalidLease) + } + + _, _, err = store.AcquireAvailableLease(context.Background(), "", time.Now(), time.Hour) + if !errors.Is(err, ErrInvalidLease) { + t.Fatalf("AcquireAvailableLease missing repo validation: got %v want %v", err, ErrInvalidLease) + } +} + +func TestLeaseStateForReleaseDerivesStateFromOperationResult(t *testing.T) { + tests := []struct { + name string + release LeaseRelease + want core.WorkspaceState + wantErr bool + }{ + { + name: "successful clean operation releases slot", + release: LeaseRelease{ + OperationState: core.OperationSucceeded, + }, + want: core.WorkspaceAvailable, + }, + { + name: "cancelled clean operation releases slot", + release: LeaseRelease{ + OperationState: core.OperationCancelled, + }, + want: core.WorkspaceAvailable, + }, + { + name: "failed clean operation marks error", + release: LeaseRelease{ + OperationState: core.OperationFailed, + }, + want: core.WorkspaceError, + }, + { + name: "dirty result wins over operation success", + release: LeaseRelease{ + OperationState: core.OperationSucceeded, + Dirty: true, + }, + want: core.WorkspaceDirty, + }, + { + name: "running operation cannot release slot", + release: LeaseRelease{ + OperationState: core.OperationRunning, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := leaseStateForRelease(tt.release) + if tt.wantErr { + if !errors.Is(err, ErrInvalidLease) { + t.Fatalf("leaseStateForRelease error: got %v want %v", err, ErrInvalidLease) + } + return + } + if err != nil { + t.Fatalf("leaseStateForRelease error: %v", err) + } + if got != tt.want { + t.Fatalf("leaseStateForRelease state: got %q want %q", got, tt.want) + } + }) + } +} diff --git a/services/core/internal/storage/postgres_test.go b/services/core/internal/storage/postgres_test.go index aca137a..8f34290 100644 --- a/services/core/internal/storage/postgres_test.go +++ b/services/core/internal/storage/postgres_test.go @@ -2,11 +2,13 @@ package storage_test import ( "context" + "errors" "fmt" "net/url" "os" "strconv" "strings" + "sync" "testing" "time" @@ -16,6 +18,232 @@ import ( "git.toki-labs.com/toki/gito/services/core/internal/storage" ) +func TestPostgresRepoStoreCreatesListsUpdatesAndValidates(t *testing.T) { + dsn := os.Getenv("GITO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("GITO_TEST_DATABASE_URL not set; skipping Postgres integration test") + } + + ctx := context.Background() + dsn = isolatedPostgresDSN(t, ctx, dsn) + + migrationData, err := os.ReadFile("../../migrations/00001_initial.sql") + if err != nil { + t.Fatalf("read migration: %v", err) + } + + store, err := storage.NewPgStore(ctx, dsn, string(migrationData)) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer store.Close() + + repo := core.Repo{ + ID: "nomadcode", + Name: "NomadCode", + RemoteURL: "ssh://git.example.invalid/toki/nomadcode.git", + DefaultBranch: "develop", + WorkspaceRoot: "/workspaces/nomadcode", + CredentialRef: "credential:gito/nomadcode", + } + if err := store.Repos().CreateRepo(ctx, repo); err != nil { + t.Fatalf("create repo: %v", err) + } + if err := store.Repos().CreateRepo(ctx, repo); !errors.Is(err, storage.ErrRepoAlreadyExists) { + t.Fatalf("duplicate repo id error: got %v want %v", err, storage.ErrRepoAlreadyExists) + } + if err := store.Repos().CreateRepo(ctx, core.Repo{ID: "missing-remote"}); !errors.Is(err, storage.ErrInvalidRepo) { + t.Fatalf("required field validation error: got %v want %v", err, storage.ErrInvalidRepo) + } + + got, err := store.Repos().GetRepo(ctx, repo.ID) + if err != nil { + t.Fatalf("get repo: %v", err) + } + if got != repo { + t.Fatalf("get repo: got %+v want %+v", got, repo) + } + + repos, err := store.Repos().ListRepos(ctx) + if err != nil { + t.Fatalf("list repos: %v", err) + } + if len(repos) != 1 || repos[0] != repo { + t.Fatalf("list repos: got %+v want [%+v]", repos, repo) + } + + updated := core.Repo{ + ID: repo.ID, + Name: "NomadCode Core", + RemoteURL: repo.RemoteURL, + DefaultBranch: "main", + WorkspaceRoot: "/workspaces/nomadcode-main", + } + if err := store.Repos().UpdateRepo(ctx, updated); err != nil { + t.Fatalf("update repo: %v", err) + } + got, err = store.Repos().GetRepo(ctx, repo.ID) + if err != nil { + t.Fatalf("get updated repo: %v", err) + } + if got != updated { + t.Fatalf("updated repo: got %+v want %+v", got, updated) + } + + if _, err := store.Repos().GetRepo(ctx, "missing"); !errors.Is(err, storage.ErrRepoNotFound) { + t.Fatalf("missing repo error: got %v want %v", err, storage.ErrRepoNotFound) + } + missingUpdate := updated + missingUpdate.ID = "missing" + if err := store.Repos().UpdateRepo(ctx, missingUpdate); !errors.Is(err, storage.ErrRepoNotFound) { + t.Fatalf("missing update error: got %v want %v", err, storage.ErrRepoNotFound) + } +} + +func TestPostgresWorkspaceLeaseStoreAcquiresOneAvailableSlotConcurrently(t *testing.T) { + dsn := os.Getenv("GITO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("GITO_TEST_DATABASE_URL not set; skipping Postgres integration test") + } + + ctx := context.Background() + dsn = isolatedPostgresDSN(t, ctx, dsn) + + migrationData, err := os.ReadFile("../../migrations/00001_initial.sql") + if err != nil { + t.Fatalf("read migration: %v", err) + } + + store, err := storage.NewPgStore(ctx, dsn, string(migrationData)) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer store.Close() + + repo := core.Repo{ + ID: "lease-repo", + Name: "Lease Repo", + RemoteURL: "ssh://git.example.invalid/toki/lease-repo.git", + DefaultBranch: "main", + WorkspaceRoot: "/workspaces/lease-repo", + } + if err := store.Repos().CreateRepo(ctx, repo); err != nil { + t.Fatalf("create repo: %v", err) + } + + seedLeases := []core.WorkspaceLease{ + { + ID: "lease-001", + RepoID: repo.ID, + Slot: "001", + Path: "/workspaces/lease-repo/001", + State: core.WorkspaceAvailable, + }, + { + ID: "lease-002", + RepoID: repo.ID, + Slot: "002", + Path: "/workspaces/lease-repo/002", + State: core.WorkspaceAvailable, + }, + } + for _, lease := range seedLeases { + if err := store.WorkspaceLeases().SaveLease(ctx, lease); err != nil { + t.Fatalf("save lease %s: %v", lease.ID, err) + } + } + + const workers = 8 + start := make(chan struct{}) + type acquireResult struct { + lease core.WorkspaceLease + ok bool + err error + } + results := make(chan acquireResult, workers) + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + lease, ok, err := store.WorkspaceLeases().AcquireAvailableLease(ctx, repo.ID, time.Now().UTC(), time.Hour) + results <- acquireResult{lease: lease, ok: ok, err: err} + }() + } + close(start) + wg.Wait() + close(results) + + acquired := make(map[string]core.WorkspaceLease) + for result := range results { + if result.err != nil { + t.Fatalf("acquire lease: %v", result.err) + } + if !result.ok { + continue + } + if _, exists := acquired[result.lease.ID]; exists { + t.Fatalf("lease %s was acquired more than once", result.lease.ID) + } + acquired[result.lease.ID] = result.lease + } + if len(acquired) != len(seedLeases) { + t.Fatalf("acquired lease count: got %d want %d", len(acquired), len(seedLeases)) + } + for _, lease := range acquired { + if lease.State != core.WorkspaceLeased || lease.ExpiresAt == nil { + t.Fatalf("acquired lease state: got %+v", lease) + } + } + + released, err := store.WorkspaceLeases().ReleaseLease(ctx, "lease-001", storage.LeaseRelease{ + OperationState: core.OperationSucceeded, + }) + if err != nil { + t.Fatalf("release successful lease: %v", err) + } + if released.State != core.WorkspaceAvailable || released.ExpiresAt != nil { + t.Fatalf("released clean lease: got %+v", released) + } + + errored, err := store.WorkspaceLeases().ReleaseLease(ctx, "lease-002", storage.LeaseRelease{ + OperationState: core.OperationFailed, + }) + if err != nil { + t.Fatalf("release failed lease: %v", err) + } + if errored.State != core.WorkspaceError || errored.ExpiresAt != nil { + t.Fatalf("released failed lease: got %+v", errored) + } + + reacquired, ok, err := store.WorkspaceLeases().AcquireAvailableLease(ctx, repo.ID, time.Now().UTC(), time.Hour) + if err != nil { + t.Fatalf("reacquire lease: %v", err) + } + if !ok || reacquired.ID != "lease-001" { + t.Fatalf("reacquired lease: got %+v ok=%v want lease-001", reacquired, ok) + } + dirty, err := store.WorkspaceLeases().ReleaseLease(ctx, reacquired.ID, storage.LeaseRelease{ + OperationState: core.OperationSucceeded, + Dirty: true, + }) + if err != nil { + t.Fatalf("release dirty lease: %v", err) + } + if dirty.State != core.WorkspaceDirty || dirty.ExpiresAt != nil { + t.Fatalf("released dirty lease: got %+v", dirty) + } + + listed, err := store.WorkspaceLeases().ListLeases(ctx, repo.ID) + if err != nil { + t.Fatalf("list leases: %v", err) + } + if len(listed) != len(seedLeases) { + t.Fatalf("listed leases: got %d want %d", len(listed), len(seedLeases)) + } +} + func TestPostgresBranchEventStorePersistsWatchCursorAndDedupesDelivery(t *testing.T) { dsn := os.Getenv("GITO_TEST_DATABASE_URL") if dsn == "" { diff --git a/services/core/internal/storage/storage.go b/services/core/internal/storage/storage.go index 45ecada..c4ea6a8 100644 --- a/services/core/internal/storage/storage.go +++ b/services/core/internal/storage/storage.go @@ -2,6 +2,7 @@ package storage import ( "context" + "errors" "fmt" "time" @@ -21,15 +22,32 @@ type Store interface { } type RepoStore interface { - SaveRepo(ctx context.Context, repo core.Repo) error + CreateRepo(ctx context.Context, repo core.Repo) error GetRepo(ctx context.Context, id string) (core.Repo, error) ListRepos(ctx context.Context) ([]core.Repo, error) + UpdateRepo(ctx context.Context, repo core.Repo) error } +var ( + ErrInvalidRepo = errors.New("invalid repo") + ErrRepoAlreadyExists = errors.New("repo already exists") + ErrRepoNotFound = errors.New("repo not found") + ErrInvalidLease = errors.New("invalid workspace lease") + ErrLeaseAlreadyExists = errors.New("workspace lease already exists") + ErrLeaseNotFound = errors.New("workspace lease not found") +) + type WorkspaceLeaseStore interface { SaveLease(ctx context.Context, lease core.WorkspaceLease) error GetLease(ctx context.Context, id string) (core.WorkspaceLease, error) ListLeases(ctx context.Context, repoID string) ([]core.WorkspaceLease, error) + AcquireAvailableLease(ctx context.Context, repoID string, now time.Time, ttl time.Duration) (core.WorkspaceLease, bool, error) + ReleaseLease(ctx context.Context, id string, release LeaseRelease) (core.WorkspaceLease, error) +} + +type LeaseRelease struct { + OperationState core.OperationState + Dirty bool } type OperationStore interface { @@ -126,10 +144,10 @@ func (b *Boundary) Ping(ctx context.Context) error { return nil } -func (b *Boundary) Repos() RepoStore { return b.repos } -func (b *Boundary) WorkspaceLeases() WorkspaceLeaseStore { return b.leases } -func (b *Boundary) Operations() OperationStore { return b.ops } -func (b *Boundary) OperationEvents() OperationEventStore { return b.opEvents } -func (b *Boundary) BranchWatches() BranchWatchStore { return b.watches } -func (b *Boundary) RevisionCursors() RevisionCursorStore { return b.cursors } +func (b *Boundary) Repos() RepoStore { return b.repos } +func (b *Boundary) WorkspaceLeases() WorkspaceLeaseStore { return b.leases } +func (b *Boundary) Operations() OperationStore { return b.ops } +func (b *Boundary) OperationEvents() OperationEventStore { return b.opEvents } +func (b *Boundary) BranchWatches() BranchWatchStore { return b.watches } +func (b *Boundary) RevisionCursors() RevisionCursorStore { return b.cursors } func (b *Boundary) ProviderDeliveries() ProviderDeliveryStore { return b.deliveries } diff --git a/services/core/internal/storage/storage_test.go b/services/core/internal/storage/storage_test.go index 2d9b6db..97a906f 100644 --- a/services/core/internal/storage/storage_test.go +++ b/services/core/internal/storage/storage_test.go @@ -22,9 +22,10 @@ var _ storage.ProviderDeliveryStore = (*fakeProviderDeliveries)(nil) type fakeRepos struct{} -func (f *fakeRepos) SaveRepo(_ context.Context, _ core.Repo) error { return nil } +func (f *fakeRepos) CreateRepo(_ context.Context, _ core.Repo) error { return nil } func (f *fakeRepos) GetRepo(_ context.Context, _ string) (core.Repo, error) { return core.Repo{}, nil } func (f *fakeRepos) ListRepos(_ context.Context) ([]core.Repo, error) { return nil, nil } +func (f *fakeRepos) UpdateRepo(_ context.Context, _ core.Repo) error { return nil } var _ storage.RepoStore = (*fakeRepos)(nil) @@ -37,6 +38,12 @@ func (f *fakeLeases) GetLease(_ context.Context, _ string) (core.WorkspaceLease, func (f *fakeLeases) ListLeases(_ context.Context, _ string) ([]core.WorkspaceLease, error) { return nil, nil } +func (f *fakeLeases) AcquireAvailableLease(_ context.Context, _ string, _ time.Time, _ time.Duration) (core.WorkspaceLease, bool, error) { + return core.WorkspaceLease{}, false, nil +} +func (f *fakeLeases) ReleaseLease(_ context.Context, _ string, _ storage.LeaseRelease) (core.WorkspaceLease, error) { + return core.WorkspaceLease{}, nil +} var _ storage.WorkspaceLeaseStore = (*fakeLeases)(nil) @@ -171,6 +178,13 @@ func TestInitialMigrationSupportsStoreBoundary(t *testing.T) { // required column/constraint snippets columnSnippets := []string{ + "remote_url TEXT NOT NULL", + "default_branch TEXT NOT NULL", + "workspace_root TEXT NOT NULL", + "credential_ref TEXT", + "state TEXT NOT NULL", + "expires_at TIMESTAMPTZ", + "UNIQUE (repo_id, slot)", "operation_id", "ALTER TABLE revision_cursors DROP CONSTRAINT IF EXISTS revision_cursors_repo_id_fkey", "UNIQUE (provider, repo_id, branch)",