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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,16 @@ Inspects and updates the active profile's `agent_sources`. `add` normalizes the
path, skips duplicates, and preserves unrelated sources. `remove` removes only
matching normalized paths from the active profile.

Before each `cr review`, cr checks whether every profile agent source is
current. For a source in a Git checkout whose branch tracks an upstream, cr
fetches that upstream (up to 15 seconds). If the checkout is behind, cr prints
a warning on stderr with the commit count and the `git merge --ff-only` command
that updates it, so reviewers merged upstream don't go missing without notice.
If the fetch fails, cr compares against the last fetched upstream state and
notes that it could not confirm the source is current. A
source outside Git, or on a branch with no upstream, gets a one-line note that
cr cannot check it. The check never fails a review.

### `cr config retention`

```text
Expand Down
192 changes: 192 additions & 0 deletions internal/agentfreshness/agentfreshness.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// Package agentfreshness reports profile agent sources whose local checkout may
// not match the shared catalog it tracks.
//
// Profile agent sources are local directories, so a reviewer merged upstream
// does not run until someone updates the checkout. This package makes that
// visible before a review: it fetches each source's upstream branch and reports
// checkouts that are behind, plus sources cr cannot check at all. It never
// fails a review.
package agentfreshness

import (
"context"
"errors"
"fmt"
"io"
"os/exec"
"strconv"
"strings"
"time"

"github.com/open-cli-collective/codereview-cli/internal/agents"
)

// fetchTimeout bounds the upstream fetch so an unreachable remote delays a
// review by seconds, not minutes.
const fetchTimeout = 15 * time.Second

// GitCommand runs git in dir and returns its standard output.
type GitCommand func(ctx context.Context, dir string, args ...string) ([]byte, error)

// Kind classifies a notice.
type Kind string

const (
// KindUnversioned reports a source outside any Git worktree.
KindUnversioned Kind = "unversioned"
// KindNoUpstream reports a source whose checked-out branch tracks no upstream.
KindNoUpstream Kind = "no_upstream"
// KindBehind reports a checkout missing commits from its upstream.
KindBehind Kind = "behind"
// KindFetchFailed reports a checkout that matched its last fetched upstream
// but whose upstream could not be fetched to confirm it is current.
KindFetchFailed Kind = "fetch_failed"
)

// Notice describes one agent source that may not reflect its shared catalog.
type Notice struct {
Kind Kind
Source string
RepoRoot string
Upstream string
Behind int
// FetchError is set when the upstream could not be fetched, so Behind was
// counted against the last fetched upstream state.
FetchError string
}

// Warn checks dirs with the host git and writes any notices to w. A nil w
// skips the check entirely.
func Warn(ctx context.Context, dirs []string, w io.Writer) {
if w == nil || len(dirs) == 0 {
return
}
Write(w, Check(ctx, dirs, defaultGitCommand))
}

// Check reports sources that are unversioned, track no upstream, or are behind
// their upstream. Missing or unreadable sources are left to the agent loader,
// and sources sharing a checkout are checked once.
func Check(ctx context.Context, dirs []string, run GitCommand) []Notice {
var notices []Notice
seenSources := map[string]bool{}
seenRepos := map[string]bool{}
for _, source := range agents.InspectProfileSources(dirs) {
if ctx.Err() != nil {
return notices
}
if source.CanonicalPath == "" || seenSources[source.CanonicalPath] {
continue
}
seenSources[source.CanonicalPath] = true
out, err := run(ctx, source.CanonicalPath, "rev-parse", "--show-toplevel")
if err != nil {
notices = append(notices, Notice{Kind: KindUnversioned, Source: source.ConfiguredPath})
continue
}
root := strings.TrimSpace(string(out))
if seenRepos[root] {
continue
}
seenRepos[root] = true
if notice, ok := checkRepo(ctx, run, source.ConfiguredPath, root); ok {
notices = append(notices, notice)
}
}
return notices
}

func checkRepo(ctx context.Context, run GitCommand, source, root string) (Notice, bool) {
out, err := run(ctx, root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
if err != nil {
return Notice{Kind: KindNoUpstream, Source: source, RepoRoot: root}, true
}
upstream := strings.TrimSpace(string(out))
fetchCtx, cancel := context.WithTimeout(ctx, fetchTimeout)
_, fetchErr := run(fetchCtx, root, "fetch", "--quiet", "--no-tags")
cancel()
out, err = run(ctx, root, "rev-list", "--count", "HEAD..@{upstream}")
if err != nil {
return Notice{}, false
}
behind, err := strconv.Atoi(strings.TrimSpace(string(out)))
if err != nil {
return Notice{}, false
}
notice := Notice{Kind: KindBehind, Source: source, RepoRoot: root, Upstream: upstream, Behind: behind}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A failed fetch with a last-fetched count of zero produces no notice, so an unreachable upstream is indistinguishable from a successfully verified current checkout. This defeats the freshness check precisely when the cached upstream may be stale. Emit a notice whenever fetching fails, including when the cached count is zero, and test that case.

Reply inline to this comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8436ab9. A failed fetch now always produces a notice: a behind warning that names the fetch failure, or a note: saying cr couldn't confirm the source is current when the last fetch shows it current. Tested by TestCheckNotesUnreachableUpstreamWhenLastFetchIsCurrent.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing this and adding coverage for the current cached count.

Summary:
The author reports that failed fetches now always produce a notice, including when the cached upstream count is zero, with a test covering that case.

if fetchErr != nil {
notice.FetchError = fetchErr.Error()
if behind == 0 {
notice.Kind = KindFetchFailed
}
}
return notice, notice.Kind == KindFetchFailed || behind > 0
}

// Write renders notices as one line each.
func Write(w io.Writer, notices []Notice) {
for _, notice := range notices {
_, _ = fmt.Fprintln(w, notice.String())
}
}

func (n Notice) String() string {
switch n.Kind {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suggested update command inserts the repository path without shell quoting. A checkout under a path containing spaces or shell metacharacters produces a command that fails or is interpreted differently when copied into a shell. Quote the path and upstream as shell arguments, and add a rendering test using a checkout path containing spaces.

Reply inline to this comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8436ab9. The repo path and upstream in the update command are now quoted as POSIX shell words when they need it. Tested by TestBehindNoticeQuotesUpdateCommandArguments with a path containing a space and a single quote.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing the argument quoting and adding a test for a path with spaces and a single quote.

Summary:
The author reports that the update command now quotes the repository path and upstream as POSIX shell words when needed, with a rendering test covering a path containing a space and a single quote.

case KindUnversioned:
return fmt.Sprintf("note: agent source %s is not in a Git repository; cr cannot check whether its reviewers are current", n.Source)
case KindNoUpstream:
return fmt.Sprintf("note: agent source %s is in %s, whose branch tracks no upstream; cr cannot check whether its reviewers are current", n.Source, n.RepoRoot)
case KindBehind:
line := fmt.Sprintf("warning: agent source %s is %d %s behind %s; reviewer changes merged there will not run. Update with: git -C %s merge --ff-only %s",
n.Source, n.Behind, plural(n.Behind, "commit", "commits"), n.Upstream, shellQuote(n.RepoRoot), shellQuote(n.Upstream))
if n.FetchError != "" {
line += fmt.Sprintf(" (fetch failed, counted from the last fetch: %s)", firstLine(n.FetchError))
}
return line
case KindFetchFailed:
return fmt.Sprintf("note: agent source %s matches the last fetch of %s, but cr could not fetch it to confirm it is current: %s", n.Source, n.Upstream, firstLine(n.FetchError))
default:
return fmt.Sprintf("note: agent source %s: %s", n.Source, n.Kind)
}
}

func firstLine(s string) string {
line, _, _ := strings.Cut(s, "\n")
return line
}

// shellQuote returns s as one POSIX shell word, so the suggested update
// command can be pasted as-is.
func shellQuote(s string) string {
if s != "" && strings.Trim(s, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._-+:@%=,") == "" {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

func plural(n int, one, many string) string {
if n == 1 {
return one
}
return many
}

func defaultGitCommand(ctx context.Context, dir string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "git", args...) // #nosec G204 -- fixed git command with structured arguments.
cmd.Dir = dir
cmd.Env = append(cmd.Environ(), "LC_ALL=C", "LANG=C", "GIT_TERMINAL_PROMPT=0")
output, err := cmd.Output()
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if message := strings.TrimSpace(string(exitErr.Stderr)); message != "" {
return nil, fmt.Errorf("git %s: %s", strings.Join(args, " "), message)
}
}
return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
}
return output, nil
}
Loading
Loading