From e657bce398458ba37c565d5ea52c35e99b6c94a1 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 13 Jun 2026 23:40:57 +0900 Subject: [PATCH] update roadmap and gitengine command implementation --- .../platformless-git-operation-engine.md | 22 +++++++----- .../phase/control-plane-foundation/PHASE.md | 14 ++++---- .../milestones/operation-event-outbox.md | 3 +- services/core/internal/gitengine/command.go | 35 ++++++++++++++++--- .../core/internal/gitengine/command_test.go | 27 ++++++++++++++ 5 files changed, 78 insertions(+), 23 deletions(-) rename agent-roadmap/{ => archive}/phase/control-plane-foundation/milestones/platformless-git-operation-engine.md (66%) diff --git a/agent-roadmap/phase/control-plane-foundation/milestones/platformless-git-operation-engine.md b/agent-roadmap/archive/phase/control-plane-foundation/milestones/platformless-git-operation-engine.md similarity index 66% rename from agent-roadmap/phase/control-plane-foundation/milestones/platformless-git-operation-engine.md rename to agent-roadmap/archive/phase/control-plane-foundation/milestones/platformless-git-operation-engine.md index 5a917bc..2c9a5ae 100644 --- a/agent-roadmap/phase/control-plane-foundation/milestones/platformless-git-operation-engine.md +++ b/agent-roadmap/archive/phase/control-plane-foundation/milestones/platformless-git-operation-engine.md @@ -11,7 +11,7 @@ GitHub/GitLab/Gitea 같은 플랫폼 API 없이 Git CLI와 local bare repository ## 상태 -[진행중] +[완료] ## 승격 조건 @@ -45,19 +45,23 @@ Provider API에 의존하지 않는 Git command 기능을 만든다. branch revision 변화와 changed file을 normalized event 입력으로 만든다. -- [ ] [cursor] repo/branch별 last seen revision cursor를 저장한다. +- [x] [cursor] repo/branch별 last seen revision cursor를 저장한다. 검증: `go test ./internal/controlplane ./internal/storage ./internal/gitengine`에서 cursor store와 scan cursor 상태를 확인한다. - [x] [changed-files] `before..after` changed file scan을 구현한다. 검증: 여러 commit 사이 변경 파일 목록이 기대와 일치한다. -- [ ] [event-input] scan 결과를 `RevisionEvent` 후보로 변환한다. +- [x] [event-input] scan 결과를 `RevisionEvent` 후보로 변환한다. 검증: `TestRuntimeScanBranchRevisionBuildsRevisionEvent`에서 revision bounds와 changed files를 확인한다. ## 완료 리뷰 -- 상태: 없음 -- 요청일: 없음 -- 완료 근거: 없음 +- 상태: 승인됨 +- 요청일: 2026-06-13 +- 완료 근거: + - `services/core/internal/storage`의 `RevisionCursorStore`와 Postgres 구현이 repo/branch cursor 저장과 조회를 지원한다. + - `services/core/internal/controlplane`의 `ScanBranchRevision`이 cursor, changed file scan, `RevisionEvent` 변환을 연결한다. + - 코드 레벨 완료 리뷰에서 `gitengine.CLI` timeout 경계와 `--name-status` path 파싱 edge case를 보완했다. + - `go test ./...`와 `go vet ./...` 통과. - 리뷰 필요: - - [ ] 사용자가 완료 결과를 확인했다 - - [ ] archive 이동을 승인했다 -- 리뷰 코멘트: 없음 + - [x] 사용자가 완료 결과를 확인했다 + - [x] archive 이동을 승인했다 +- 리뷰 코멘트: 코드 레벨 리뷰와 보완 검증이 완료되어 archive 이동 대상으로 확정했다. ## 범위 제외 diff --git a/agent-roadmap/phase/control-plane-foundation/PHASE.md b/agent-roadmap/phase/control-plane-foundation/PHASE.md index b053764..d4c6aaa 100644 --- a/agent-roadmap/phase/control-plane-foundation/PHASE.md +++ b/agent-roadmap/phase/control-plane-foundation/PHASE.md @@ -18,19 +18,19 @@ Gito의 초기 control plane을 build 가능한 modular monolith로 구성한다 - 경로: `agent-roadmap/archive/phase/control-plane-foundation/milestones/forgejo-branch-event-mvp.md` - 요약: Forgejo push webhook을 watched branch `branch.updated` event로 정규화하고 NomadCode가 proto-socket으로 받을 수 있는 MVP slice를 만든다. -- [검토중] Runtime Scaffold and Contract Baseline - - 경로: `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/archive/phase/control-plane-foundation/milestones/repo-registry-and-workspace-lease.md` - 요약: 관리 대상 repo와 workspace slot/lease를 Postgres source of truth로 저장하고 안전하게 할당한다. -- [진행중] Platformless Git Operation Engine - - 경로: `agent-roadmap/phase/control-plane-foundation/milestones/platformless-git-operation-engine.md` +- [완료] Platformless Git Operation Engine + - 경로: `agent-roadmap/archive/phase/control-plane-foundation/milestones/platformless-git-operation-engine.md` - 요약: clone, fetch, status, diff, commit, push, revision scan을 provider 없이 local bare repo 기반으로 검증한다. -- [계획] Operation Event Outbox +- [검토중] Runtime Scaffold and Contract Baseline + - 경로: `agent-roadmap/phase/control-plane-foundation/milestones/runtime-scaffold-and-contract-baseline.md` + - 요약: Go command, Flutter control surface placeholder, contracts, agent-ops, README, architecture 문서를 실행 가능한 기준선으로 정리한다. + +- [진행중] Operation Event Outbox - 경로: `agent-roadmap/phase/control-plane-foundation/milestones/operation-event-outbox.md` - 요약: operation lifecycle, idempotency, audit event, outbox 발행을 PostgreSQL 기준으로 구성한다. diff --git a/agent-roadmap/phase/control-plane-foundation/milestones/operation-event-outbox.md b/agent-roadmap/phase/control-plane-foundation/milestones/operation-event-outbox.md index 6b7ebf8..436c196 100644 --- a/agent-roadmap/phase/control-plane-foundation/milestones/operation-event-outbox.md +++ b/agent-roadmap/phase/control-plane-foundation/milestones/operation-event-outbox.md @@ -11,7 +11,7 @@ operation lifecycle과 normalized event를 PostgreSQL 원장에 기록하고, pr ## 상태 -[계획] +[진행중] ## 승격 조건 @@ -72,4 +72,3 @@ operation 결과를 durable event로 남기고 fanout 대상이 읽을 수 있 - 선행 작업: Platformless Git Operation Engine - 후속 작업: Agent Shell Runtime Channel - 확인 필요: 없음 - diff --git a/services/core/internal/gitengine/command.go b/services/core/internal/gitengine/command.go index 7433504..b51e8f8 100644 --- a/services/core/internal/gitengine/command.go +++ b/services/core/internal/gitengine/command.go @@ -1,28 +1,45 @@ package gitengine import ( + "context" "errors" + "fmt" "os/exec" "strings" + "time" ) var ErrInvalidGitInput = errors.New("invalid git input") +const DefaultCommandTimeout = 2 * time.Minute + type CommandRunner interface { Run(workdir string, args ...string) (string, error) } -type CLI struct{} +type CLI struct { + Timeout time.Duration +} -func (CLI) Run(workdir string, args ...string) (string, error) { +func (c CLI) Run(workdir string, args ...string) (string, error) { if len(args) == 0 { return "", ErrInvalidGitInput } - cmd := exec.Command("git", args...) + timeout := c.Timeout + if timeout <= 0 { + timeout = DefaultCommandTimeout + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "git", args...) if strings.TrimSpace(workdir) != "" { cmd.Dir = workdir } out, err := cmd.CombinedOutput() + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return string(out), fmt.Errorf("git command timed out after %s: %w", timeout, ctx.Err()) + } return string(out), err } @@ -226,11 +243,19 @@ func ChangedFilesWithStatus(runner CommandRunner, workdir, before, after string) } func parseNameStatus(line string) (ChangedFile, bool) { - fields := strings.Fields(strings.TrimSpace(line)) + line = strings.TrimSuffix(line, "\r") + if strings.TrimSpace(line) == "" { + return ChangedFile{}, false + } + fields := strings.Split(line, "\t") if len(fields) < 2 { return ChangedFile{}, false } - changeType := changeTypeFromGitStatus(fields[0]) + status := strings.TrimSpace(fields[0]) + if status == "" { + return ChangedFile{}, false + } + changeType := changeTypeFromGitStatus(status) path := fields[len(fields)-1] if path == "" { return ChangedFile{}, false diff --git a/services/core/internal/gitengine/command_test.go b/services/core/internal/gitengine/command_test.go index cf2d713..f141ad1 100644 --- a/services/core/internal/gitengine/command_test.go +++ b/services/core/internal/gitengine/command_test.go @@ -1,6 +1,7 @@ package gitengine import ( + "context" "errors" "os" "os/exec" @@ -8,6 +9,7 @@ import ( "reflect" "strings" "testing" + "time" ) type fakeRunner struct { @@ -38,6 +40,13 @@ func (f *fakeRunner) Run(workdir string, args ...string) (string, error) { return output, err } +func TestCLIRunTimesOut(t *testing.T) { + _, err := (CLI{Timeout: 10 * time.Millisecond}).Run("", "-c", "alias.wait=!sleep 1", "wait") + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected context deadline exceeded, got %v", err) + } +} + func TestStatusClean(t *testing.T) { runner := &fakeRunner{outputs: []string{"\n"}} result, err := Status(runner, "/repo") @@ -184,6 +193,24 @@ func TestChangedFilesWithStatus(t *testing.T) { } } +func TestChangedFilesWithStatusPreservesWhitespaceInPaths(t *testing.T) { + runner := &fakeRunner{outputs: []string{ + "A\tdocs/user guide.md\nM\tpath with spaces/file name.go\nR100\told name.md\tnew name.md\n", + }} + files, err := ChangedFilesWithStatus(runner, "/repo", "abc", "def") + if err != nil { + t.Fatalf("ChangedFilesWithStatus: %v", err) + } + want := []ChangedFile{ + {Path: "docs/user guide.md", ChangeType: "added"}, + {Path: "path with spaces/file name.go", ChangeType: "modified"}, + {Path: "new name.md", ChangeType: "renamed"}, + } + if !reflect.DeepEqual(files, want) { + t.Fatalf("files:\n got %#v\nwant %#v", files, want) + } +} + func TestChangedFilesWithStatusLocalCommits(t *testing.T) { _, seedPath, initialRev := setupBareRemote(t)