Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions internal/app/contribution.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,14 +199,14 @@ func (s *Service) workspaceDiff(ctx context.Context, workspaceID string, inv *in
if err != nil {
return "", err
}
hasUntracked, err := mgr.HasUntrackedByPath(ctx, ws.Path)
hasUntracked, err := mgr.HasUntrackedWorkspace(ctx, ws)
if err != nil {
return "", err
}
if hasUntracked {
return "", errors.New("workspace has untracked files; stage them or provide an explicit --changes summary")
}
return mgr.DiffByPath(ctx, ws.Path, ws.BaseSHA)
return mgr.DiffWorkspace(ctx, ws)
}

// PrepareReviewReportInput scopes a review report to an opportunity and/or workspace.
Expand Down
2 changes: 1 addition & 1 deletion internal/app/mcp_scalable_operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ func (r *MCPReader) CheckMergeConflicts(ctx context.Context, in mcpserver.CheckM
out.Items[index] = item
continue
}
result, err := manager.CheckMerge(ctx, ws.Path, current.BaseOID, current.HeadOID)
result, err := manager.CheckMergeWorkspace(ctx, ws, current.BaseOID, current.HeadOID)
if err != nil {
item.Status, item.Reason, item.Message = "failed", "merge_check_failed", err.Error()
out.Items[index] = item
Expand Down
14 changes: 14 additions & 0 deletions internal/app/mcp_v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,20 @@ func (r *MCPReader) CreateWorkspace(ctx context.Context, in mcpserver.CreateWork
return queuedJobReference(id, "create_workspace", "workspace creation job started"), nil
}

// AdoptWorkspace records an existing worktree synchronously without exposing
// its host path or remote URL in the protocol result.
func (r *MCPReader) AdoptWorkspace(ctx context.Context, in mcpserver.AdoptWorkspaceInput) (mcpserver.AdoptWorkspaceOutput, error) {
res, err := r.Service.AdoptWorkspace(ctx, in.InvestigationID, cli.WorkspaceAdoptOptions{Path: in.Path, BaseRef: in.BaseRef, Name: in.Name})
if err != nil {
return mcpserver.AdoptWorkspaceOutput{}, err
}
return mcpserver.AdoptWorkspaceOutput{
ID: res.ID, InvestigationID: res.InvestigationID, Owner: res.Repo.Owner, Repo: res.Repo.Repo,
BaseSHA: res.BaseSHA, CandidateSHA: res.CandidateSHA, MergeBase: res.MergeBase,
Dirty: res.Dirty, HasUntracked: res.HasUntracked, Ownership: res.Ownership,
}, nil
}

// RunValidation submits a durable validation run.
func (r *MCPReader) RunValidation(ctx context.Context, in mcpserver.RunValidationInput) (mcpserver.JobReference, error) {
runKind := evidence.RunKind(in.Kind)
Expand Down
71 changes: 63 additions & 8 deletions internal/app/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/google/uuid"
"github.com/morluto/gitcontribute/internal/cli"
"github.com/morluto/gitcontribute/internal/gitremote"
"github.com/morluto/gitcontribute/internal/workspace"
)

Expand All @@ -27,6 +28,55 @@ func (s *Service) workspaceManager(ctx context.Context) (*workspace.Manager, err
return workspace.NewManager(root, nil)
}

// AdoptWorkspace records an existing local worktree without fetching,
// changing its refs, or taking ownership of its files.
func (s *Service) AdoptWorkspace(ctx context.Context, investigationID string, opts cli.WorkspaceAdoptOptions) (*cli.WorkspaceResult, error) {
invSvc, err := s.writeInvestigationSvc(ctx)
if err != nil {
return nil, err
}
inv, err := invSvc.GetInvestigation(ctx, investigationID)
if err != nil {
return nil, mapInvestigationError(err)
}
requestedName := strings.TrimSpace(opts.Name)
name := requestedName
if name == "" {
name = uuid.NewString()
}
mgr, err := s.workspaceManager(ctx)
if err != nil {
return nil, err
}
ws, err := mgr.Adopt(ctx, workspace.AdoptOptions{Path: opts.Path, BaseRef: opts.BaseRef, Name: name})
if err != nil {
return nil, fmt.Errorf("adopt worktree: %w", err)
}
identity, err := gitremote.ParseRepositoryIdentity(ws.Remote)
if err != nil {
return nil, fmt.Errorf("identify origin repository: %w", err)
}
if !strings.EqualFold(identity.Host, "github.com") || !strings.EqualFold(identity.Owner, inv.Repo.Owner) || !strings.EqualFold(identity.Repo, inv.Repo.Repo) {
return nil, fmt.Errorf("origin repository %s/%s does not match investigation repository %s/%s", identity.Owner, identity.Repo, inv.Repo.Owner, inv.Repo.Repo)
}
ws.InvestigationID, ws.RepoOwner, ws.RepoName = inv.ID, inv.Repo.Owner, inv.Repo.Repo
c, err := s.openCorpus(ctx)
if err != nil {
return nil, err
}
bound, inserted, err := c.BindWorkspacePath(ctx, ws)
if errors.Is(err, workspace.ErrExists) {
return nil, fmt.Errorf("workspace name %q already exists", name)
}
if err != nil {
return nil, err
}
if !inserted && (bound.InvestigationID != inv.ID || (requestedName != "" && bound.Name != name)) {
return nil, fmt.Errorf("worktree path is already bound to workspace %q", bound.Name)
}
return workspaceResult(bound), nil
}

func (s *Service) workspaceReader() (*workspace.Manager, error) {
dataDir, err := s.paths.DataDir()
if err != nil {
Expand Down Expand Up @@ -121,11 +171,14 @@ func (s *Service) ShowWorkspace(ctx context.Context, id string) (*cli.WorkspaceR
}

mgr, err := s.workspaceReader()
if err == nil {
if st, err := mgr.StatusByPath(ctx, ws.Path); err == nil {
ws.Dirty = st.Dirty
}
if err != nil {
return nil, err
}
st, err := mgr.StatusWorkspace(ctx, ws)
if err != nil {
return nil, fmt.Errorf("read workspace status: %w", err)
}
ws.Dirty = st.Dirty

return workspaceResult(ws), nil
}
Expand Down Expand Up @@ -170,22 +223,22 @@ func (s *Service) WorkspaceDiff(ctx context.Context, id string) (*WorkspaceDiffR
return nil, err
}

st, err := mgr.StatusByPath(ctx, ws.Path)
st, err := mgr.StatusWorkspace(ctx, ws)
if err != nil {
return nil, err
}

hasUntracked, err := mgr.HasUntrackedByPath(ctx, ws.Path)
hasUntracked, err := mgr.HasUntrackedWorkspace(ctx, ws)
if err != nil {
return nil, err
}

diff, err := mgr.DiffByPath(ctx, ws.Path, ws.BaseSHA)
diff, err := mgr.DiffWorkspace(ctx, ws)
if err != nil {
return nil, err
}

files, err := mgr.ChangedFilesByPath(ctx, ws.Path, ws.BaseSHA)
files, err := mgr.ChangedFilesWorkspace(ctx, ws)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -276,6 +329,8 @@ func workspaceResult(ws *workspace.Workspace) *cli.WorkspaceResult {
CandidateSHA: ws.CandidateSHA,
MergeBase: ws.MergeBase,
Dirty: ws.Dirty,
HasUntracked: ws.HasUntracked,
Ownership: string(ws.Ownership),
CreatedAt: formatTime(ws.CreatedAt),
}
}
Expand Down
73 changes: 73 additions & 0 deletions internal/app/workspace_security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,76 @@ func TestWorkspaceCreateAndShow(t *testing.T) {
t.Fatalf("workspace roundtrip failed: %+v", shown)
}
}

func TestAdoptWorkspaceIsIdempotentAndUsable(t *testing.T) {
t.Parallel()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
ctx := context.Background()
remote, baseSHA, candidateSHA := setupAppGitRemote(t)
source := filepath.Join(filepath.Dir(remote), "src")
external := filepath.Join(t.TempDir(), "existing")
runGitApp(t, source, "worktree", "add", "--detach", external, candidateSHA)
runGitApp(t, source, "remote", "set-url", "origin", "https://github.com/owner/repo.git")
writeAppFile(t, filepath.Join(external, "feature.txt"), "changed")
writeAppFile(t, filepath.Join(external, "untracked.txt"), "new")

paths := config.NewPaths(&config.Env{Home: t.TempDir()})
svc, err := New(paths, "test", nil)
if err != nil {
t.Fatal(err)
}
defer func() { _ = svc.Close() }()
if _, err := svc.Init(ctx); err != nil {
t.Fatal(err)
}
inv, err := svc.StartInvestigation(ctx, cli.RepoRef{Owner: "owner", Repo: "repo"}, candidateSHA, "")
if err != nil {
t.Fatal(err)
}
first, err := svc.AdoptWorkspace(ctx, inv.ID, cli.WorkspaceAdoptOptions{Path: external, BaseRef: "master"})
if err != nil {
t.Fatal(err)
}
second, err := svc.AdoptWorkspace(ctx, inv.ID, cli.WorkspaceAdoptOptions{Path: external, BaseRef: "master"})
if err != nil {
t.Fatal(err)
}
if first.ID != second.ID || first.Ownership != "external" || first.BaseSHA != baseSHA || !first.Dirty || !first.HasUntracked {
t.Fatalf("unexpected adoption results: first=%+v second=%+v", first, second)
}
diff, err := svc.WorkspaceDiff(ctx, first.ID)
if err != nil {
t.Fatal(err)
}
if diff.Diff == "" || !diff.HasUntracked {
t.Fatalf("adopted diff missing state: %+v", diff)
}
}

func TestAdoptWorkspaceRejectsRepositoryMismatch(t *testing.T) {
t.Parallel()
ctx := context.Background()
remote, _, candidateSHA := setupAppGitRemote(t)
source := filepath.Join(filepath.Dir(remote), "src")
external := filepath.Join(t.TempDir(), "existing")
runGitApp(t, source, "worktree", "add", "--detach", external, candidateSHA)
runGitApp(t, source, "remote", "set-url", "origin", "https://github.com/other/repo.git")
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
svc, err := New(paths, "test", nil)
if err != nil {
t.Fatal(err)
}
defer func() { _ = svc.Close() }()
if _, err := svc.Init(ctx); err != nil {
t.Fatal(err)
}
inv, err := svc.StartInvestigation(ctx, cli.RepoRef{Owner: "owner", Repo: "repo"}, candidateSHA, "")
if err != nil {
t.Fatal(err)
}
if _, err := svc.AdoptWorkspace(ctx, inv.ID, cli.WorkspaceAdoptOptions{Path: external, BaseRef: "master"}); err == nil || !strings.Contains(err.Error(), "does not match") {
t.Fatalf("AdoptWorkspace mismatch error = %v", err)
}
}
49 changes: 0 additions & 49 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package cli

import (
Expand Down Expand Up @@ -892,14 +892,6 @@
return service, nil
}

func (c *CLI) workspaceService() (WorkspaceService, error) {
service, ok := c.svc.(WorkspaceService)
if !ok {
return nil, NewCLIError(ExitNotWired, ErrNotWired)
}
return service, nil
}

func (c *CLI) validationService() (ValidationService, error) {
service, ok := c.svc.(ValidationService)
if !ok {
Expand Down Expand Up @@ -1392,47 +1384,6 @@
return c.render(cmd.Check.JSON, result)
}

func (c *CLI) runWorkspace(ctx context.Context, command string, cmd *workspaceCmd) error {
service, err := c.workspaceService()
if err != nil {
return err
}
switch command {
case "workspace create":
fmt.Fprintf(c.stderr, "creating workspace for investigation %s...\n", cmd.Create.InvestigationID)
result, err := service.CreateWorkspace(ctx, cmd.Create.InvestigationID, WorkspaceCreateOptions{
Remote: cmd.Create.Remote,
BaseRef: cmd.Create.Base,
CandidateRef: cmd.Create.Candidate,
Name: cmd.Create.Name,
})
if err != nil {
return c.mapError(err)
}
return c.render(cmd.Create.JSON, result)
case "workspace show":
result, err := service.ShowWorkspace(ctx, cmd.Show.ID)
if err != nil {
return c.mapError(err)
}
return c.render(cmd.Show.JSON, result)
default:
return NewCLIError(ExitUsage, fmt.Errorf("unknown workspace command: %s", command))
}
}

func (c *CLI) runDiff(ctx context.Context, cmd *diffCmd) error {
service, err := c.workflowExtensionService()
if err != nil {
return err
}
result, err := service.WorkspaceDiffForCLI(ctx, cmd.WorkspaceID)
if err != nil {
return c.mapError(err)
}
return c.render(cmd.JSON, result)
}

func formatCommand(args []string) string {
quoted := make([]string, len(args))
for i, arg := range args {
Expand Down
19 changes: 19 additions & 0 deletions internal/cli/extended_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli_test

import (
"context"
"path/filepath"
"strings"
"testing"
"time"
Expand All @@ -13,6 +14,7 @@ type fakeExtendedService struct {
*fakeService

createWorkspaceCalled bool
adoptWorkspaceCalled bool
showWorkspaceCalled bool
defineValidationCalled bool
runValidationCalled bool
Expand All @@ -36,6 +38,7 @@ type fakeExtendedService struct {

lastWorkspaceInvestigation string
lastCreateWorkspaceOpts cli.WorkspaceCreateOptions
lastAdoptWorkspaceOpts cli.WorkspaceAdoptOptions
lastShowWorkspaceID string
lastValidationInvestigation string
lastDefineValidationOpts cli.DefineValidationOptions
Expand All @@ -61,6 +64,13 @@ func (f *fakeExtendedService) CreateWorkspace(ctx context.Context, investigation
return f.workspaceResult, f.err
}

func (f *fakeExtendedService) AdoptWorkspace(_ context.Context, investigationID string, opts cli.WorkspaceAdoptOptions) (*cli.WorkspaceResult, error) {
f.adoptWorkspaceCalled = true
f.lastWorkspaceInvestigation = investigationID
f.lastAdoptWorkspaceOpts = opts
return f.workspaceResult, f.err
}

func (f *fakeExtendedService) ShowWorkspace(ctx context.Context, id string) (*cli.WorkspaceResult, error) {
f.showWorkspaceCalled = true
f.lastShowWorkspaceID = id
Expand Down Expand Up @@ -156,6 +166,15 @@ func TestWorkspaceCreateAndShow(t *testing.T) {
t.Fatalf("stdout=%q stderr=%q", stdout.String(), stderr.String())
}

stdout.Reset()
err = c.Run(context.Background(), []string{"workspace", "adopt", "inv-1", "--path", "/tmp/existing", "--base", "main", "--name", "external"})
requireNoErr(t, err)
expectedAdoptPath, err := filepath.Abs("/tmp/existing")
requireNoErr(t, err)
if !svc.adoptWorkspaceCalled || svc.lastAdoptWorkspaceOpts.Path != expectedAdoptPath || svc.lastAdoptWorkspaceOpts.BaseRef != "main" {
t.Fatalf("adopt workspace args = %+v", svc.lastAdoptWorkspaceOpts)
}

stdout.Reset()
err = c.Run(context.Background(), []string{"workspace", "show", "ws-1"})
requireNoErr(t, err)
Expand Down
10 changes: 10 additions & 0 deletions internal/cli/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -415,9 +415,17 @@ type HealthService interface {
// WorkspaceService is the optional workspace management capability used by the CLI.
type WorkspaceService interface {
CreateWorkspace(ctx context.Context, investigationID string, opts WorkspaceCreateOptions) (*WorkspaceResult, error)
AdoptWorkspace(ctx context.Context, investigationID string, opts WorkspaceAdoptOptions) (*WorkspaceResult, error)
ShowWorkspace(ctx context.Context, id string) (*WorkspaceResult, error)
}

// WorkspaceAdoptOptions identifies an existing local Git worktree.
type WorkspaceAdoptOptions struct {
Path string
BaseRef string
Name string
}

// WorkspaceCreateOptions carries explicit local-write intent for workspace creation.
type WorkspaceCreateOptions struct {
Remote string
Expand All @@ -437,6 +445,8 @@ type WorkspaceResult struct {
CandidateSHA string `json:"candidate_sha"`
MergeBase string `json:"merge_base"`
Dirty bool `json:"dirty"`
HasUntracked bool `json:"has_untracked"`
Ownership string `json:"ownership"`
CreatedAt string `json:"created_at"`
}

Expand Down
Loading
Loading