diff --git a/README.md b/README.md index 8d60173..c01adb1 100644 --- a/README.md +++ b/README.md @@ -518,10 +518,11 @@ ignore the relevant context while writing the upstream commit message. Braid does not generate an upstream subject for you, and leaving the guidance in place does not add it to the final commit message. -This guidance is best-effort. If Braid cannot compute it safely, or if -`core.commentChar` is set to `auto`, Braid prints a warning and opens the editor -without the provenance block; the push still proceeds through the normal commit -and push checks. +This guidance is best-effort. If a historical upstream revision is unavailable +locally, Braid prints an informational message unless `--quiet` is active and +opens the editor without the provenance block. Other failures that prevent Braid +from computing the guidance safely, including `core.commentChar=auto`, print a +warning. The push still proceeds through the normal commit and push checks. To prefill the editor with a generated draft message, set `BRAID_PUSH_COMMIT_MESSAGE_COMMAND` to a trusted local POSIX shell command. Empty diff --git a/internal/command/preflight.go b/internal/command/preflight.go index 42315cc..ced1bef 100644 --- a/internal/command/preflight.go +++ b/internal/command/preflight.go @@ -109,6 +109,7 @@ type PushGit interface { ConfigGet(context.Context, ...string) (string, bool, error) CoreCommentChar(context.Context) (string, bool, error) ShellPath(context.Context) (string, error) + ResolveRevision(context.Context, string) (string, bool, error) FirstParentCommits(context.Context, string) ([]string, error) LogCommitsTouchingPath(context.Context, string, string) ([]gitexec.Commit, error) ShowFile(context.Context, string, string) ([]byte, bool, error) diff --git a/internal/command/push.go b/internal/command/push.go index fbfb041..f2f4565 100644 --- a/internal/command/push.go +++ b/internal/command/push.go @@ -262,7 +262,7 @@ func (h PushHandler) push(ctx context.Context, repo RepoContext, git PushGit, m if commitMessage == "" { provenance, provenanceOK, provenanceErr = buildPushProvenance(ctx, git, m) if provenanceErr != nil { - warnPushProvenance(stderr, provenanceErr) + reportPushProvenanceFailure(stderr, global.Quiet, provenanceErr) } messageGeneration, err = resolvePushMessageGeneration(ctx, git, messageGeneration) if err != nil { @@ -502,6 +502,17 @@ func warnPushProvenance(stderr io.Writer, err error) { _, _ = fmt.Fprintf(stderr, "Braid: warning: push provenance guidance skipped: %v\n", err) } +func reportPushProvenanceFailure(stderr io.Writer, quiet bool, err error) { + var unavailable *pushProvenanceRevisionUnavailableError + if errors.As(err, &unavailable) { + if !quiet { + _, _ = fmt.Fprintf(stderr, "Braid: push provenance guidance unavailable: %v\n", err) + } + return + } + warnPushProvenance(stderr, err) +} + func writeAlternates(ctx context.Context, source PushGit, tempDir, sourceWorkDir string) error { objectsPath, err := source.RepoFilePath(ctx, "objects") if err != nil { diff --git a/internal/command/push_provenance.go b/internal/command/push_provenance.go index 0da53f4..1c5b4e9 100644 --- a/internal/command/push_provenance.go +++ b/internal/command/push_provenance.go @@ -35,6 +35,15 @@ type pushProvenanceWindow struct { NoCleanAnchor bool } +type pushProvenanceRevisionUnavailableError struct { + Source string + Revision string +} + +func (e *pushProvenanceRevisionUnavailableError) Error() string { + return fmt.Sprintf("historical upstream revision %s for source :%s is unavailable locally", e.Revision, e.Source) +} + func buildPushProvenance(ctx context.Context, git PushGit, m source.SourceMirror) (pushProvenance, bool, error) { byHash := map[string]pushProvenanceCommit{} noAnchor := false @@ -165,10 +174,20 @@ func mirrorCleanAtCommit(ctx context.Context, git PushGit, commit string, m sour } func recordedMirrorItem(ctx context.Context, git PushGit, m source.SourceMirror) (gitexec.TreeItem, error) { + revision, ok, err := git.ResolveRevision(ctx, m.Revision+"^{commit}") + if err != nil { + return gitexec.TreeItem{}, err + } + if !ok { + return gitexec.TreeItem{}, &pushProvenanceRevisionUnavailableError{ + Source: m.Name, + Revision: m.Revision, + } + } if m.UpstreamPath == "" { - return git.TreeItem(ctx, m.Revision) + return git.TreeItem(ctx, revision) } - return git.LsTreeItem(ctx, m.Revision, m.UpstreamPath) + return git.LsTreeItem(ctx, revision, m.UpstreamPath) } func collectPushProvenanceCommits(ctx context.Context, git PushGit, current source.SourceMirror, revisionRange string) ([]pushProvenanceCommit, int, error) { diff --git a/internal/command/push_test.go b/internal/command/push_test.go index f731d77..df21749 100644 --- a/internal/command/push_test.go +++ b/internal/command/push_test.go @@ -854,6 +854,49 @@ func TestPushCommandProvenanceSurvivesUpdateAndExcludesBraidCommit(t *testing.T) assertFile(t, upstream, "remote.txt", "remote change\n") } +func TestPushCommandHistoricalRevisionUnavailableIsInformational(t *testing.T) { + upstream := testutil.InitRepo(t) + testutil.WriteFile(t, upstream, "local.txt", "base local\n") + testutil.WriteFile(t, upstream, "remote.txt", "base remote\n") + historicalRevision := testutil.CommitAll(t, upstream, "base") + + repo := initDownstream(t) + runCommandOK(t, repo, []string{"add", upstream, "vendor/basic"}) + testutil.WriteFile(t, repo, "vendor/basic/local.txt", "local change\n") + commitAllWithMessage(t, repo, "local before update") + testutil.WriteFile(t, upstream, "remote.txt", "remote change\n") + currentRevision := testutil.CommitAll(t, upstream, "remote update") + runCommandOK(t, repo, []string{"update", "vendor/basic"}) + + parent := t.TempDir() + clone := filepath.Join(parent, "clone") + testutil.Git(t, parent, "clone", "--no-local", repo, clone) + testutil.Git(t, clone, "fetch", "--depth=1", upstream, currentRevision) + git := gitexec.New(clone, false, nil) + if resolved, ok, err := git.ResolveRevision(context.Background(), historicalRevision+"^{commit}"); err != nil { + t.Fatalf("inspect historical revision: %v", err) + } else if ok { + t.Fatalf("historical revision unexpectedly available at %s", resolved) + } + + _, ok, provenanceErr := buildPushProvenance(context.Background(), git, loadMirror(t, clone, "vendor/basic")) + if provenanceErr == nil { + t.Fatal("buildPushProvenance returned nil error, want unavailable historical revision") + } + if ok { + t.Fatal("buildPushProvenance returned ok, want false") + } + + var stderr bytes.Buffer + reportPushProvenanceFailure(&stderr, false, provenanceErr) + assertContains(t, stderr.String(), "Braid: push provenance guidance unavailable: historical upstream revision "+historicalRevision+" for source :001 is unavailable locally") + assertNotContains(t, stderr.String(), "Braid: warning: push provenance guidance skipped") + + stderr.Reset() + reportPushProvenanceFailure(&stderr, true, provenanceErr) + assertNotContains(t, stderr.String(), "push provenance guidance") +} + func TestPushCommandProvenanceCapShowsNewestTwentyFiveChronologically(t *testing.T) { upstream := testutil.InitRepo(t) testutil.WriteFile(t, upstream, "README.md", "base\n") diff --git a/internal/gitexec/gitexec.go b/internal/gitexec/gitexec.go index bd4e02a..9eb6824 100644 --- a/internal/gitexec/gitexec.go +++ b/internal/gitexec/gitexec.go @@ -288,6 +288,14 @@ func (g Git) RevParse(ctx context.Context, rev string) (string, error) { return g.Output(ctx, "rev-parse", rev) } +func (g Git) ResolveRevision(ctx context.Context, rev string) (string, bool, error) { + resolved, err := g.optionalRevParse(ctx, rev) + if err != nil { + return "", false, err + } + return resolved, resolved != "", nil +} + func (g Git) Head(ctx context.Context) (string, error) { return g.RevParse(ctx, "HEAD") } diff --git a/internal/gitexec/gitexec_test.go b/internal/gitexec/gitexec_test.go index fbe5277..e6dd37d 100644 --- a/internal/gitexec/gitexec_test.go +++ b/internal/gitexec/gitexec_test.go @@ -359,6 +359,17 @@ func TestHistoryHelpersReadCommitsFilesAndTrees(t *testing.T) { head := realGitOutput(t, repo, "rev-parse", "HEAD") git := New(repo, false, nil) + resolved, ok, err := git.ResolveRevision(context.Background(), "HEAD^{commit}") + if err != nil { + t.Fatalf("ResolveRevision HEAD returned error: %v", err) + } + if !ok || resolved != head { + t.Fatalf("ResolveRevision HEAD = %q, %v, want %q, true", resolved, ok, head) + } + if resolved, ok, err := git.ResolveRevision(context.Background(), "refs/heads/missing^{commit}"); err != nil || ok || resolved != "" { + t.Fatalf("ResolveRevision missing = %q, %v, %v, want empty, false, nil", resolved, ok, err) + } + commits, err := git.FirstParentCommits(context.Background(), "HEAD") if err != nil { t.Fatalf("FirstParentCommits returned error: %v", err)