Attribute accepted commits to the prompting user's configured Git identity
Hop-State: A_06FN8A13TXD2FW71C2B7MDR Hop-Proposal: R_06FN89ZN929C6HYFDPJ2B48 Hop-Task: T_06FN892CVE210TZR2ZQQ9JG Hop-Attempt: AT_06FN892CVDGHFG3F889Q3YR
This commit is contained in:
+40
-1
@@ -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()
|
||||
|
||||
+13
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 <prompter@example.com>\nHop <hop@localhost>"
|
||||
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"})
|
||||
|
||||
Reference in New Issue
Block a user