Skip to content

Commit 8635b00

Browse files
authored
Merge pull request #121 from morluto/feature/hunk-semantic-plan
feat(commitplan): add hunk-aware semantic commit planning
2 parents 9b275e6 + 56f01ef commit 8635b00

18 files changed

Lines changed: 1198 additions & 3 deletions

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,13 @@ gitcontribute tui owner/repo
663663
`diff` returns the patch, changed files, and suggested review order. The TUI is
664664
local-only; add `--json` to emit a non-interactive snapshot.
665665

666+
MCP's `code` toolset also exposes a read-only semantic commit workflow. An
667+
agent first calls `workspace.inspect_commit_changes` for stable file/hunk IDs,
668+
then submits its proposed groups to `workspace.plan_semantic_commits`. The
669+
second tool verifies exact one-to-one coverage and reports ambiguous, mixed,
670+
generated, binary, formatting-only, and untracked changes. It does not stage or
671+
commit anything.
672+
666673
</details>
667674

668675
<details>

docs/architecture.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,19 @@ Database and WAL bytes remain whole-file measurements because SQLite pages are
455455
shared. Logical observation-payload and code-content bytes are reported
456456
separately rather than attributed to individual database pages.
457457

458+
Semantic commit preparation is a two-step local read. First,
459+
`workspace.inspect_commit_changes` asks Git for a binary/full-index patch and
460+
untracked blob identities, then uses the maintained `sourcegraph/go-diff`
461+
parser to expose stable file and hunk units. Second, the agent supplies semantic
462+
grouping judgment to `workspace.plan_semantic_commits`; deterministic code
463+
rejects stale inventories, unknown or duplicate unit assignments, and invalid
464+
dependency graphs. Ambiguous units remain explicit. A verified reconstruction
465+
record binds one-to-one unit coverage to the exact source patch and untracked
466+
content identities. Neither operation stages files, applies patches, creates
467+
commits, changes refs, executes repository code, or contacts GitHub. Applying a
468+
plan is intentionally a separate future capability with an explicit mutation
469+
boundary.
470+
458471
Storage changes should include tests for upgrade behavior, rollback when
459472
supported, stale-write rejection, transaction atomicity, and deterministic
460473
query ordering.

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ require (
1717
github.com/modelcontextprotocol/go-sdk v1.6.1
1818
github.com/pelletier/go-toml/v2 v2.4.3
1919
github.com/pressly/goose/v3 v3.24.0
20+
github.com/sourcegraph/go-diff v0.8.0
2021
github.com/zalando/go-keyring v0.2.8
2122
golang.org/x/mod v0.37.0
2223
golang.org/x/sys v0.46.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfv
131131
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
132132
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
133133
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
134+
github.com/sourcegraph/go-diff v0.8.0 h1:ipIyu4cTsLbIrln4l0qtHA3r0a7gyK4ntKjtQytHhvY=
135+
github.com/sourcegraph/go-diff v0.8.0/go.mod h1:hWlcO7Al+UZStZAP8rBumHpCK5ZHQ5BXsMls8p4+F5E=
134136
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
135137
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
136138
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=

internal/app/commitplan.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package app
2+
3+
import (
4+
"context"
5+
"errors"
6+
7+
"github.com/morluto/gitcontribute/internal/commitplan"
8+
)
9+
10+
// InspectCommitChanges returns stable assignable units for a managed workspace.
11+
// It reads only the local worktree and Git object database.
12+
func (s *Service) InspectCommitChanges(ctx context.Context, workspaceID string) (commitplan.Inventory, error) {
13+
return s.commitPlanInventory(ctx, workspaceID)
14+
}
15+
16+
// PlanSemanticCommits validates agent-authored semantic groups against the
17+
// current workspace snapshot. It never stages changes or rewrites history.
18+
func (s *Service) PlanSemanticCommits(ctx context.Context, workspaceID, expectedInventorySHA256 string, input commitplan.PlanInput) (commitplan.Plan, error) {
19+
inventory, err := s.commitPlanInventory(ctx, workspaceID)
20+
if err != nil {
21+
return commitplan.Plan{}, err
22+
}
23+
if expectedInventorySHA256 != "" && expectedInventorySHA256 != inventory.InventorySHA256 {
24+
return commitplan.Plan{}, errors.New("workspace diff changed after inspection; inspect again before planning")
25+
}
26+
return commitplan.Build(ctx, inventory, input)
27+
}
28+
29+
func (s *Service) commitPlanInventory(ctx context.Context, workspaceID string) (commitplan.Inventory, error) {
30+
c, err := s.openReadOnlyCorpus(ctx)
31+
if err != nil {
32+
return commitplan.Inventory{}, err
33+
}
34+
ws, err := c.GetWorkspace(ctx, workspaceID)
35+
if err != nil {
36+
return commitplan.Inventory{}, mapWorkspaceError(err)
37+
}
38+
mgr, err := s.workspaceReader()
39+
if err != nil {
40+
return commitplan.Inventory{}, err
41+
}
42+
patch, err := mgr.DiffByPath(ctx, ws.Path, ws.BaseSHA)
43+
if err != nil {
44+
return commitplan.Inventory{}, err
45+
}
46+
untracked, err := mgr.UntrackedFilesByPath(ctx, ws.Path)
47+
if err != nil {
48+
return commitplan.Inventory{}, err
49+
}
50+
files := make([]commitplan.UntrackedFile, len(untracked))
51+
for index, file := range untracked {
52+
files[index] = commitplan.UntrackedFile{Path: file.Path, ObjectID: file.ObjectID}
53+
}
54+
return commitplan.Inspect(ctx, commitplan.Snapshot{Patch: []byte(patch), Untracked: files})
55+
}

internal/app/commitplan_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package app
2+
3+
import (
4+
"context"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"testing"
9+
10+
"github.com/morluto/gitcontribute/internal/cli"
11+
"github.com/morluto/gitcontribute/internal/commitplan"
12+
)
13+
14+
func TestSemanticCommitPlanUsesFrozenWorkspaceSnapshot(t *testing.T) {
15+
if _, err := exec.LookPath("git"); err != nil {
16+
t.Skip("git not available")
17+
}
18+
ctx := context.Background()
19+
svc := newLocalService(t)
20+
defer func() { _ = svc.Close() }()
21+
remote, _, candidateSHA := setupAppGitRemote(t)
22+
inv, err := svc.StartInvestigation(ctx, cli.RepoRef{Owner: "owner", Repo: "repo"}, candidateSHA, "")
23+
if err != nil {
24+
t.Fatal(err)
25+
}
26+
ws, err := svc.CreateWorkspace(ctx, inv.ID, cli.WorkspaceCreateOptions{Remote: remote, BaseRef: "master", CandidateRef: "feature", Name: "commit-plan"})
27+
if err != nil {
28+
t.Fatal(err)
29+
}
30+
if err := os.WriteFile(filepath.Join(ws.Path, "untracked.txt"), []byte("proof\n"), 0o644); err != nil {
31+
t.Fatal(err)
32+
}
33+
inventory, err := svc.InspectCommitChanges(ctx, ws.ID)
34+
if err != nil {
35+
t.Fatal(err)
36+
}
37+
if len(inventory.Units) < 2 {
38+
t.Fatalf("inventory = %+v", inventory)
39+
}
40+
ids := make([]string, len(inventory.Units))
41+
for index := range inventory.Units {
42+
ids[index] = inventory.Units[index].ID
43+
}
44+
plan, err := svc.PlanSemanticCommits(ctx, ws.ID, inventory.InventorySHA256, commitplan.PlanInput{Groups: []commitplan.GroupInput{{
45+
Name: "feature", Intent: "add feature", Type: "feat", UnitIDs: ids,
46+
}}})
47+
if err != nil {
48+
t.Fatal(err)
49+
}
50+
if !plan.Reconstruction.Verified {
51+
t.Fatalf("plan = %+v", plan)
52+
}
53+
if err := os.WriteFile(filepath.Join(ws.Path, "changed-after-inspection.txt"), []byte("later\n"), 0o644); err != nil {
54+
t.Fatal(err)
55+
}
56+
if _, err := svc.PlanSemanticCommits(ctx, ws.ID, inventory.InventorySHA256, commitplan.PlanInput{}); err == nil {
57+
t.Fatal("expected stale snapshot rejection")
58+
}
59+
}

internal/app/mcp_commitplan.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package app
2+
3+
import (
4+
"context"
5+
6+
"github.com/morluto/gitcontribute/internal/commitplan"
7+
"github.com/morluto/gitcontribute/internal/mcpserver"
8+
)
9+
10+
// InspectCommitChanges implements the MCP commit-inventory capability.
11+
func (r *MCPReader) InspectCommitChanges(ctx context.Context, in mcpserver.InspectCommitChangesInput) (mcpserver.CommitInventoryOutput, error) {
12+
inventory, err := r.Service.InspectCommitChanges(ctx, in.WorkspaceID)
13+
if err != nil {
14+
return mcpserver.CommitInventoryOutput{}, err
15+
}
16+
return commitInventoryToMCP(inventory), nil
17+
}
18+
19+
// PlanSemanticCommits implements the MCP read-only semantic planning capability.
20+
func (r *MCPReader) PlanSemanticCommits(ctx context.Context, in mcpserver.PlanSemanticCommitsInput) (mcpserver.SemanticCommitPlanOutput, error) {
21+
groups := make([]commitplan.GroupInput, len(in.Groups))
22+
for index, group := range in.Groups {
23+
groups[index] = commitplan.GroupInput{
24+
Name: group.Name, Intent: group.Intent, Type: group.Type, Scope: group.Scope,
25+
UnitIDs: group.UnitIDs, DependsOn: group.DependsOn,
26+
ValidationCommands: group.ValidationCommands, TestOwners: group.TestOwners,
27+
}
28+
}
29+
unresolved := make([]commitplan.UnresolvedInput, len(in.Unresolved))
30+
for index, item := range in.Unresolved {
31+
unresolved[index] = commitplan.UnresolvedInput{UnitID: item.UnitID, Reason: item.Reason}
32+
}
33+
plan, err := r.Service.PlanSemanticCommits(ctx, in.WorkspaceID, in.ExpectedInventorySHA256, commitplan.PlanInput{Groups: groups, Unresolved: unresolved})
34+
if err != nil {
35+
return mcpserver.SemanticCommitPlanOutput{}, err
36+
}
37+
return semanticCommitPlanToMCP(plan), nil
38+
}
39+
40+
func commitInventoryToMCP(inventory commitplan.Inventory) mcpserver.CommitInventoryOutput {
41+
out := mcpserver.CommitInventoryOutput{SourcePatchSHA256: inventory.SourcePatchSHA256, InventorySHA256: inventory.InventorySHA256}
42+
for _, unit := range inventory.Units {
43+
out.Units = append(out.Units, mcpserver.CommitUnitOutput{
44+
ID: unit.ID, Kind: unit.Kind, Path: unit.Path, OldPath: unit.OldPath, Operation: unit.Operation,
45+
OldStart: unit.OldStart, OldLines: unit.OldLines, NewStart: unit.NewStart, NewLines: unit.NewLines,
46+
Patch: unit.Patch, ContentSHA256: unit.ContentHash, Generated: unit.Generated, WhitespaceOnly: unit.WhitespaceOnly,
47+
})
48+
}
49+
out.Warnings = commitWarningsToMCP(inventory.Warnings)
50+
return out
51+
}
52+
53+
func semanticCommitPlanToMCP(plan commitplan.Plan) mcpserver.SemanticCommitPlanOutput {
54+
out := mcpserver.SemanticCommitPlanOutput{
55+
Warnings: commitWarningsToMCP(plan.Warnings),
56+
Reconstruction: mcpserver.CommitReconstructionOutput{
57+
SourcePatchSHA256: plan.Reconstruction.SourcePatchSHA256, InventorySHA256: plan.Reconstruction.InventorySHA256,
58+
AssignedSHA256: plan.Reconstruction.AssignedSHA256, UnitCount: plan.Reconstruction.UnitCount,
59+
AssignedCount: plan.Reconstruction.AssignedCount, Verified: plan.Reconstruction.Verified,
60+
},
61+
}
62+
for _, group := range plan.Groups {
63+
out.Groups = append(out.Groups, mcpserver.SemanticCommitGroupOutput{
64+
Name: group.Name, Intent: group.Intent, SuggestedSubject: group.SuggestedSubject,
65+
UnitIDs: group.UnitIDs, Files: group.Files, DependsOn: group.DependsOn,
66+
ValidationCommands: group.ValidationCommands, TestOwners: group.TestOwners,
67+
})
68+
}
69+
for _, item := range plan.Unresolved {
70+
out.Unresolved = append(out.Unresolved, mcpserver.UnresolvedCommitUnitOutput{UnitID: item.UnitID, Reason: item.Reason})
71+
}
72+
return out
73+
}
74+
75+
func commitWarningsToMCP(warnings []commitplan.Warning) []mcpserver.CommitPlanWarningOutput {
76+
out := make([]mcpserver.CommitPlanWarningOutput, len(warnings))
77+
for index, warning := range warnings {
78+
out[index] = mcpserver.CommitPlanWarningOutput{Code: warning.Code, Message: warning.Message, Path: warning.Path, UnitID: warning.UnitID}
79+
}
80+
return out
81+
}

internal/commitplan/models.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Package commitplan builds verifiable, read-only semantic commit plans from
2+
// Git-owned patches. It never stages files or changes repository history.
3+
package commitplan
4+
5+
// Snapshot is the immutable input observed from one workspace.
6+
type Snapshot struct {
7+
Patch []byte
8+
Untracked []UntrackedFile
9+
}
10+
11+
// UntrackedFile identifies exact untracked content without embedding it.
12+
type UntrackedFile struct {
13+
Path string
14+
ObjectID string
15+
}
16+
17+
// Unit is the smallest assignable file or hunk change.
18+
type Unit struct {
19+
ID string `json:"id"`
20+
Kind string `json:"kind"`
21+
Path string `json:"path"`
22+
OldPath string `json:"old_path,omitempty"`
23+
Operation string `json:"operation"`
24+
OldStart int32 `json:"old_start,omitempty"`
25+
OldLines int32 `json:"old_lines,omitempty"`
26+
NewStart int32 `json:"new_start,omitempty"`
27+
NewLines int32 `json:"new_lines,omitempty"`
28+
Patch string `json:"patch,omitempty"`
29+
ContentHash string `json:"content_sha256"`
30+
Generated bool `json:"generated"`
31+
WhitespaceOnly bool `json:"whitespace_only"`
32+
}
33+
34+
// Warning describes a condition requiring human or agent judgment.
35+
type Warning struct {
36+
Code string `json:"code"`
37+
Message string `json:"message"`
38+
Path string `json:"path,omitempty"`
39+
UnitID string `json:"unit_id,omitempty"`
40+
}
41+
42+
// Inventory is a bounded, deterministic view of one source patch.
43+
type Inventory struct {
44+
Units []Unit `json:"units"`
45+
Warnings []Warning `json:"warnings,omitempty"`
46+
SourcePatchSHA256 string `json:"source_patch_sha256"`
47+
InventorySHA256 string `json:"inventory_sha256"`
48+
}
49+
50+
// GroupInput supplies semantic judgment while deterministic code verifies
51+
// coverage and dependencies.
52+
type GroupInput struct {
53+
Name string
54+
Intent string
55+
Type string
56+
Scope string
57+
UnitIDs []string
58+
DependsOn []string
59+
ValidationCommands []string
60+
TestOwners []string
61+
}
62+
63+
// UnresolvedInput explains why a unit cannot yet be assigned safely.
64+
type UnresolvedInput struct {
65+
UnitID string
66+
Reason string
67+
}
68+
69+
// PlanInput is the agent-authored semantic layer over a frozen inventory.
70+
type PlanInput struct {
71+
Groups []GroupInput
72+
Unresolved []UnresolvedInput
73+
}
74+
75+
// Group is one proposed semantic commit.
76+
type Group struct {
77+
Name string `json:"name"`
78+
Intent string `json:"intent"`
79+
SuggestedSubject string `json:"suggested_subject"`
80+
UnitIDs []string `json:"unit_ids"`
81+
Files []string `json:"files"`
82+
DependsOn []string `json:"depends_on,omitempty"`
83+
ValidationCommands []string `json:"validation_commands,omitempty"`
84+
TestOwners []string `json:"test_owners,omitempty"`
85+
}
86+
87+
// Unresolved records an unassigned or ambiguous unit.
88+
type Unresolved struct {
89+
UnitID string `json:"unit_id"`
90+
Reason string `json:"reason"`
91+
}
92+
93+
// Reconstruction binds exact source bytes to one-to-one unit coverage.
94+
type Reconstruction struct {
95+
SourcePatchSHA256 string `json:"source_patch_sha256"`
96+
InventorySHA256 string `json:"inventory_sha256"`
97+
AssignedSHA256 string `json:"assigned_sha256"`
98+
UnitCount int `json:"unit_count"`
99+
AssignedCount int `json:"assigned_count"`
100+
Verified bool `json:"verified"`
101+
}
102+
103+
// Plan is a read-only semantic proposal plus deterministic coverage proof.
104+
type Plan struct {
105+
Groups []Group `json:"groups"`
106+
Unresolved []Unresolved `json:"unresolved,omitempty"`
107+
Warnings []Warning `json:"warnings,omitempty"`
108+
Reconstruction Reconstruction `json:"reconstruction"`
109+
}

0 commit comments

Comments
 (0)