diff --git a/.hop/records/prompts/P_06FN892CVDG36AN981MGF00.json b/.hop/records/prompts/P_06FN892CVDG36AN981MGF00.json new file mode 100644 index 0000000..a65fde0 --- /dev/null +++ b/.hop/records/prompts/P_06FN892CVDG36AN981MGF00.json @@ -0,0 +1,17 @@ +{ + "id": "P_06FN892CVDG36AN981MGF00", + "task_id": "T_06FN892CVE210TZR2ZQQ9JG", + "attempt_id": "AT_06FN892CVDGHFG3F889Q3YR", + "state_id": "P_06FN892CVDG36AN981MGF00", + "prompt": "implement that", + "agent_name": "codex", + "status": "proposed", + "response_summary": "Attribute accepted commits to the prompting user's configured Git identity", + "created_at": "2026-07-12T02:24:52.955741Z", + "metadata": { + "source_tree": "5895f21f33fd3d6d04b764c84dfeb6e0c4738c1f", + "git_commit": "787acb2330d8662320477d022da37ee4029697ac", + "attempt_head": "C_06FN89NDDXYFZK3XRRBQPVR", + "attempt_head_kind": "checkpoint" + } +} diff --git a/internal/hop/git.go b/internal/hop/git.go index d8e059a..a9cc5a9 100644 --- a/internal/hop/git.go +++ b/internal/hop/git.go @@ -67,7 +67,7 @@ func (e *GitError) Error() string { func (e *GitError) Unwrap() error { return e.Err } // CommitOptions controls a synthetic commit. Empty identities and timestamps -// use the GitStore defaults; user Git identity configuration is never used. +// use the GitStore defaults. type CommitOptions struct { Message string Parents []string @@ -478,6 +478,32 @@ func (r *Repository) CommitTree(ctx context.Context, tree string, parents []stri }) } +// ConfiguredUserIdentity returns the Git identity configured for the user in +// this repository. Both user.name and user.email must be present. +func (r *Repository) ConfiguredUserIdentity(ctx context.Context) (GitIdentity, bool, error) { + name, nameSet, err := r.configValue(ctx, "user.name") + if err != nil { + return GitIdentity{}, false, err + } + email, emailSet, err := r.configValue(ctx, "user.email") + if err != nil { + return GitIdentity{}, false, err + } + if !nameSet || !emailSet { + return GitIdentity{}, false, nil + } + identity := GitIdentity{Name: name, Email: email} + if err := validateIdentity(identity); err != nil { + return GitIdentity{}, false, fmt.Errorf("invalid configured user identity: %w", err) + } + return identity, true, nil +} + +// SyntheticIdentity is Hop's controlled committer identity. +func (r *Repository) SyntheticIdentity() GitIdentity { + return r.store.identity(GitIdentity{}) +} + // CommitTreeWithOptions creates a synthetic commit without invoking hooks or // consulting the user's Git identity. It does not update any ref. func (r *Repository) CommitTreeWithOptions(ctx context.Context, tree string, options CommitOptions) (string, error) { @@ -1303,6 +1329,19 @@ func (s *GitStore) identity(override GitIdentity) GitIdentity { return identity } +func (r *Repository) configValue(ctx context.Context, key string) (string, bool, error) { + output, err := r.run(ctx, nil, nil, "config", "--get", key) + if err != nil { + var gitErr *GitError + if errors.As(err, &gitErr) && gitErr.ExitCode == 1 { + return "", false, nil + } + return "", false, fmt.Errorf("read git config %s: %w", key, err) + } + value := trimLine(output) + return value, value != "", nil +} + func (s *GitStore) now() time.Time { if s.Now != nil { return s.Now() diff --git a/internal/hop/service.go b/internal/hop/service.go index 4ddffb6..dc34e56 100644 --- a/internal/hop/service.go +++ b/internal/hop/service.go @@ -757,7 +757,19 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand [] message = "Accept " + proposal.ID } message += fmt.Sprintf("\n\nHop-State: %s\nHop-Proposal: %s\nHop-Task: %s\nHop-Attempt: %s\n", acceptedID, proposal.ID, proposal.TaskID, proposal.AttemptID) - commit, err := s.Repo.CommitTree(ctx, finalTree, []string{current.GitCommit}, message) + author, configured, err := s.Repo.ConfiguredUserIdentity(ctx) + if err != nil { + return AcceptResult{}, err + } + if !configured { + author = s.Repo.SyntheticIdentity() + } + commit, err := s.Repo.CommitTreeWithOptions(ctx, finalTree, CommitOptions{ + Message: message, + Parents: []string{current.GitCommit}, + Author: author, + Committer: s.Repo.SyntheticIdentity(), + }) if err != nil { return AcceptResult{}, err } diff --git a/internal/hop/service_smoke_test.go b/internal/hop/service_smoke_test.go index eac7244..9a15c0c 100644 --- a/internal/hop/service_smoke_test.go +++ b/internal/hop/service_smoke_test.go @@ -1059,6 +1059,33 @@ func TestDisjointProposalsLandAndUndoMovesForward(t *testing.T) { } } +func TestAcceptedCommitAttributesPromptingUserAndRetainsHopCommitter(t *testing.T) { + ctx := context.Background() + service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"}) + runGitTest(t, service.Root, "config", "user.name", "Prompting User") + runGitTest(t, service.Root, "config", "user.email", "prompter@example.com") + + attempt, err := service.CreatePrompt(ctx, "Add authored change", "", "codex") + if err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(attempt.Workspace, "authored.txt"), "authored\n") + proposal, err := service.Propose(ctx, attempt.Prompt.ID, "Add authored change") + if err != nil { + t.Fatal(err) + } + accepted, err := service.Accept(ctx, proposal.Proposal.ID, nil) + if err != nil { + t.Fatal(err) + } + + metadata := runGitTest(t, service.Root, "show", "-s", "--format=%an <%ae>%n%cn <%ce>", accepted.State.GitCommit) + want := "Prompting User \nHop " + if metadata != want { + t.Fatalf("accepted commit identity = %q, want %q", metadata, want) + } +} + func TestConcurrentDisjointAcceptancesSerialize(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})