4bbeaae8f0
Agents leave their managed worktrees behind, and `agentws rm` takes one path at a time with no idea whether a branch's work is safely upstream, so clearing an accumulation by hand risks destroying unmerged commits. - classify every managed worktree: dirty, PR open, upstream, or unproven - remove only what is safe; delete the local branch only when work is upstream - prove "upstream" with merge-base and git cherry, so squash merges count - match a PR by head.label, which survives the branch deletion a merge does - dry run by default; --yes applies, --keep-branches spares every branch - read the Gitea path from origin's URL rather than assuming the owner
126 lines
3.7 KiB
Go
126 lines
3.7 KiB
Go
package agent
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// PRRef identifies a single pull request by repository and number.
|
|
type PRRef struct {
|
|
Owner string
|
|
Repo string
|
|
Number int
|
|
}
|
|
|
|
// String renders the ref in canonical owner/repo#N form.
|
|
func (r PRRef) String() string {
|
|
return fmt.Sprintf("%s/%s#%d", r.Owner, r.Repo, r.Number)
|
|
}
|
|
|
|
// RepoPath returns the "owner/repo" portion used in Gitea API URLs.
|
|
func (r PRRef) RepoPath() string {
|
|
return r.Owner + "/" + r.Repo
|
|
}
|
|
|
|
// ParsePRRef parses "owner/repo#N" or "owner/repo:N" into a PRRef.
|
|
func ParsePRRef(s string) (PRRef, error) {
|
|
s = strings.TrimSpace(s)
|
|
sep := strings.IndexAny(s, "#:")
|
|
if sep < 0 {
|
|
return PRRef{}, fmt.Errorf("invalid PR reference %q: expected owner/repo#N or owner/repo:N", s)
|
|
}
|
|
repoPart := s[:sep]
|
|
numPart := s[sep+1:]
|
|
|
|
owner, repo, ok := strings.Cut(repoPart, "/")
|
|
if !ok || owner == "" || repo == "" {
|
|
return PRRef{}, fmt.Errorf("invalid PR reference %q: repo must be owner/repo", s)
|
|
}
|
|
if strings.Contains(repo, "/") {
|
|
return PRRef{}, fmt.Errorf("invalid PR reference %q: repo must be owner/repo", s)
|
|
}
|
|
|
|
n, err := strconv.Atoi(numPart)
|
|
if err != nil || n <= 0 {
|
|
return PRRef{}, fmt.Errorf("invalid PR reference %q: PR number must be a positive integer", s)
|
|
}
|
|
return PRRef{Owner: owner, Repo: repo, Number: n}, nil
|
|
}
|
|
|
|
// ParseDurationFlag parses a duration flag value, accepting either a Go
|
|
// duration string ("30s", "1h30m") or a bare integer read as seconds ("15").
|
|
// flag names the flag so the error says which value was rejected.
|
|
func ParseDurationFlag(flag, value string) (time.Duration, error) {
|
|
s := strings.TrimSpace(value)
|
|
d, err := time.ParseDuration(s)
|
|
if err != nil {
|
|
n, nerr := strconv.Atoi(s)
|
|
if nerr != nil {
|
|
return 0, fmt.Errorf("invalid --%s value %q: want a duration such as 30s, 2m or 1h30m, or a bare number of seconds such as 15", flag, value)
|
|
}
|
|
d = time.Duration(n) * time.Second
|
|
}
|
|
if d <= 0 {
|
|
return 0, fmt.Errorf("invalid --%s value %q: must be greater than zero", flag, value)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// RemoteHost returns the host a git remote URL points at, or "" for a local
|
|
// path remote.
|
|
func RemoteHost(remote string) string {
|
|
s := strings.TrimSpace(remote)
|
|
if _, after, ok := strings.Cut(s, "://"); ok {
|
|
host, _, _ := strings.Cut(after, "/")
|
|
if _, bare, ok := strings.Cut(host, "@"); ok {
|
|
host = bare
|
|
}
|
|
return host
|
|
}
|
|
if strings.HasPrefix(s, "/") || strings.HasPrefix(s, ".") {
|
|
return ""
|
|
}
|
|
host, _, ok := strings.Cut(s, ":")
|
|
if !ok {
|
|
return ""
|
|
}
|
|
if _, bare, ok := strings.Cut(host, "@"); ok {
|
|
host = bare
|
|
}
|
|
return host
|
|
}
|
|
|
|
// RepoPathFromRemoteURL extracts the "owner/repo" API path from a git remote
|
|
// URL, accepting both https and scp-style ssh forms.
|
|
func RepoPathFromRemoteURL(remote string) (string, error) {
|
|
s := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSpace(remote), "/"), ".git")
|
|
switch {
|
|
case strings.Contains(s, "://"):
|
|
_, after, _ := strings.Cut(s, "://")
|
|
_, path, ok := strings.Cut(after, "/")
|
|
if !ok {
|
|
return "", fmt.Errorf("remote URL %q has no repo path", remote)
|
|
}
|
|
s = path
|
|
case strings.Contains(s, ":"):
|
|
_, s, _ = strings.Cut(s, ":")
|
|
}
|
|
parts := strings.Split(strings.Trim(s, "/"), "/")
|
|
if len(parts) < 2 || parts[len(parts)-2] == "" || parts[len(parts)-1] == "" {
|
|
return "", fmt.Errorf("remote URL %q is not owner/repo shaped", remote)
|
|
}
|
|
return parts[len(parts)-2] + "/" + parts[len(parts)-1], nil
|
|
}
|
|
|
|
// ParseRepo validates and splits an "owner/repo" string.
|
|
func ParseRepo(s string) (owner, repo string, err error) {
|
|
s = strings.TrimSpace(s)
|
|
owner, repo, ok := strings.Cut(s, "/")
|
|
if !ok || owner == "" || repo == "" || strings.Contains(repo, "/") {
|
|
return "", "", fmt.Errorf("invalid repo %q: expected owner/repo", s)
|
|
}
|
|
return owner, repo, nil
|
|
}
|