From 3995bd522b2ab7dd581c67730aefc8b7e4f98722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sat, 25 Jul 2026 23:27:29 +0300 Subject: [PATCH 01/15] feat(upgrade): add hand-rolled semver comparison --- internal/upgrade/semver.go | 127 ++++++++++++++++++++++++++++++++ internal/upgrade/semver_test.go | 81 ++++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 internal/upgrade/semver.go create mode 100644 internal/upgrade/semver_test.go diff --git a/internal/upgrade/semver.go b/internal/upgrade/semver.go new file mode 100644 index 0000000..6bc8587 --- /dev/null +++ b/internal/upgrade/semver.go @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package upgrade implements `commitbrief upgrade`: detecting how the +// running binary was installed, asking GitHub Releases whether a newer +// version exists, and either delegating to the owning package manager +// or replacing a manually installed binary in place. See ADR-0034. +package upgrade + +import ( + "fmt" + "strconv" + "strings" +) + +// Version is a parsed semantic version. Build metadata (+meta) is not +// modelled: CommitBrief never tags with it, and semver says it is +// ignored for precedence anyway. +type Version struct { + Major int + Minor int + Patch int + Pre string // "" for a release; "rc.1" for v1.0.0-rc.1 +} + +// ParseVersion accepts "v1.2.3", "1.2.3" and "v1.2.3-rc.1". It returns +// ok=false for anything else — most importantly the "dev" placeholder a +// locally built binary carries, which the caller turns into a "this is a +// development build" message rather than a bogus comparison. +func ParseVersion(s string) (Version, bool) { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "v") + if s == "" { + return Version{}, false + } + core := s + pre := "" + if i := strings.IndexByte(s, '-'); i >= 0 { + core, pre = s[:i], s[i+1:] + if pre == "" { + return Version{}, false + } + } + parts := strings.Split(core, ".") + if len(parts) != 3 { + return Version{}, false + } + nums := make([]int, 3) + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 { + return Version{}, false + } + nums[i] = n + } + return Version{Major: nums[0], Minor: nums[1], Patch: nums[2], Pre: pre}, true +} + +// String renders the version back in tag form (always v-prefixed). +func (v Version) String() string { + if v.Pre == "" { + return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch) + } + return fmt.Sprintf("v%d.%d.%d-%s", v.Major, v.Minor, v.Patch, v.Pre) +} + +// Compare returns -1, 0 or +1 as v sorts before, equal to, or after o, +// following semver precedence: numeric core first, then a release +// outranking any prerelease of the same core. +func (v Version) Compare(o Version) int { + if c := cmpInt(v.Major, o.Major); c != 0 { + return c + } + if c := cmpInt(v.Minor, o.Minor); c != 0 { + return c + } + if c := cmpInt(v.Patch, o.Patch); c != 0 { + return c + } + switch { + case v.Pre == "" && o.Pre == "": + return 0 + case v.Pre == "": + return 1 + case o.Pre == "": + return -1 + } + return comparePre(v.Pre, o.Pre) +} + +// comparePre orders two dot-separated prerelease strings. Per semver: +// identifiers are compared left to right; a purely numeric identifier +// sorts below an alphanumeric one and compares numerically; a shorter +// identifier list sorts below a longer one when the shared prefix is +// equal (so rc < rc.1). +func comparePre(a, b string) int { + as, bs := strings.Split(a, "."), strings.Split(b, ".") + for i := 0; i < len(as) && i < len(bs); i++ { + an, aErr := strconv.Atoi(as[i]) + bn, bErr := strconv.Atoi(bs[i]) + switch { + case aErr == nil && bErr == nil: // both numeric + if c := cmpInt(an, bn); c != 0 { + return c + } + case aErr == nil: // numeric sorts below alphanumeric + return -1 + case bErr == nil: + return 1 + default: + if c := strings.Compare(as[i], bs[i]); c != 0 { + return c + } + } + } + return cmpInt(len(as), len(bs)) +} + +func cmpInt(a, b int) int { + switch { + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 + } +} diff --git a/internal/upgrade/semver_test.go b/internal/upgrade/semver_test.go new file mode 100644 index 0000000..8d96921 --- /dev/null +++ b/internal/upgrade/semver_test.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package upgrade + +import "testing" + +func TestParseVersion(t *testing.T) { + cases := []struct { + in string + ok bool + major int + minor int + patch int + pre string + }{ + {"v1.13.0", true, 1, 13, 0, ""}, + {"1.13.0", true, 1, 13, 0, ""}, + {"v1.0.0-rc.1", true, 1, 0, 0, "rc.1"}, + {"v2.0.0-beta.10", true, 2, 0, 0, "beta.10"}, + {"dev", false, 0, 0, 0, ""}, + {"", false, 0, 0, 0, ""}, + {"v1.2", false, 0, 0, 0, ""}, + {"v1.2.x", false, 0, 0, 0, ""}, + } + for _, c := range cases { + got, ok := ParseVersion(c.in) + if ok != c.ok { + t.Fatalf("ParseVersion(%q) ok = %v, want %v", c.in, ok, c.ok) + } + if !ok { + continue + } + if got.Major != c.major || got.Minor != c.minor || got.Patch != c.patch || got.Pre != c.pre { + t.Fatalf("ParseVersion(%q) = %+v, want %d.%d.%d-%q", c.in, got, c.major, c.minor, c.patch, c.pre) + } + } +} + +func TestVersionCompare(t *testing.T) { + cases := []struct { + a string + b string + want int + }{ + {"v1.13.0", "v1.13.0", 0}, + {"v1.13.0", "v1.14.0", -1}, + {"v1.14.0", "v1.13.0", 1}, + {"v1.13.0", "v2.0.0", -1}, + {"v1.13.0", "v1.13.1", -1}, + // a release outranks its own prerelease + {"v1.14.0-rc.1", "v1.14.0", -1}, + {"v1.14.0", "v1.14.0-rc.1", 1}, + // numeric prerelease identifiers compare numerically, not lexically + {"v1.14.0-rc.2", "v1.14.0-rc.10", -1}, + {"v1.14.0-rc.1", "v1.14.0-rc.1", 0}, + // fewer identifiers sort lower when the prefix matches + {"v1.14.0-rc", "v1.14.0-rc.1", -1}, + // numeric identifiers sort below alphanumeric ones + {"v1.14.0-1", "v1.14.0-alpha", -1}, + } + for _, c := range cases { + va, ok := ParseVersion(c.a) + if !ok { + t.Fatalf("ParseVersion(%q) failed", c.a) + } + vb, ok := ParseVersion(c.b) + if !ok { + t.Fatalf("ParseVersion(%q) failed", c.b) + } + if got := va.Compare(vb); got != c.want { + t.Fatalf("Compare(%q, %q) = %d, want %d", c.a, c.b, got, c.want) + } + } +} + +func TestVersionString(t *testing.T) { + v, _ := ParseVersion("1.2.3-rc.4") + if got := v.String(); got != "v1.2.3-rc.4" { + t.Fatalf("String() = %q, want %q", got, "v1.2.3-rc.4") + } +} From f83840167788ed65bef40ac95221c858fe33155d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sat, 25 Jul 2026 23:33:43 +0300 Subject: [PATCH 02/15] feat(upgrade): detect the installation method from the binary path --- internal/upgrade/detect.go | 149 ++++++++++++++++++++++++++++++++ internal/upgrade/detect_test.go | 85 ++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 internal/upgrade/detect.go create mode 100644 internal/upgrade/detect_test.go diff --git a/internal/upgrade/detect.go b/internal/upgrade/detect.go new file mode 100644 index 0000000..96376b7 --- /dev/null +++ b/internal/upgrade/detect.go @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package upgrade + +import ( + "os" + "path/filepath" + "runtime" + "strings" +) + +// Method is how the running binary was installed. It decides whether +// `upgrade` delegates to a package manager or replaces the file itself +// (ADR-0034 §D1). +type Method string + +const ( + MethodHomebrew Method = "homebrew" + MethodScoop Method = "scoop" + MethodGoInstall Method = "go-install" + MethodManual Method = "manual" +) + +// Env is every piece of ambient state Detect reads. It is passed in +// rather than read from os.Getenv/runtime inside Detect so a macOS host +// can exercise the Windows and Scoop branches in a unit test. +type Env struct { + ExePath string // resolved (symlinks evaluated) path of the running binary + GOOS string + Scoop string // $SCOOP + UserProfile string // %USERPROFILE% + GOBIN string + GOPATH string + Home string +} + +// ResolveExe returns the running binary's path with symlinks resolved. +// Resolution is mandatory, not cosmetic: Homebrew installs the binary +// into the Cellar and links it from /bin, so an unresolved path +// hides the one marker that identifies a brew install. +func ResolveExe() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + resolved, err := filepath.EvalSymlinks(exe) + if err != nil { + // A broken or unreadable link is not fatal — fall back to the + // unresolved path and let detection do what it can. + return exe, nil + } + return resolved, nil +} + +// CurrentEnv builds an Env from the live process environment. +func CurrentEnv(exePath string) Env { + home, _ := os.UserHomeDir() + return Env{ + ExePath: exePath, + GOOS: runtime.GOOS, + Scoop: os.Getenv("SCOOP"), + UserProfile: os.Getenv("USERPROFILE"), + GOBIN: os.Getenv("GOBIN"), + GOPATH: os.Getenv("GOPATH"), + Home: home, + } +} + +// Detect classifies the installation, most specific marker first. +// Anything unrecognized is MethodManual — including distro packages we +// do not publish (nix, AUR, apt). That is safe by construction: those +// live in read-only or root-owned locations, so the write-permission +// gate aborts before a single byte is downloaded. +func Detect(env Env) Method { + p := normalizePath(env.ExePath, env.GOOS) + + if strings.Contains(p, "/cellar/commitbrief/") { + return MethodHomebrew + } + + if scoop := normalizePath(env.Scoop, env.GOOS); scoop != "" && + strings.HasPrefix(p, strings.TrimSuffix(scoop, "/")+"/apps/commitbrief/") { + return MethodScoop + } + if strings.Contains(p, "/scoop/apps/commitbrief/") { + return MethodScoop + } + + dir := pathDir(p) + for _, bin := range goBinDirs(env) { + if b := normalizePath(bin, env.GOOS); b != "" && strings.TrimSuffix(b, "/") == dir { + return MethodGoInstall + } + } + + return MethodManual +} + +// goBinDirs lists the directories `go install` could have written to, +// in the same precedence order the go command uses. +func goBinDirs(env Env) []string { + var dirs []string + if env.GOBIN != "" { + dirs = append(dirs, env.GOBIN) + } + if env.GOPATH != "" { + // GOPATH may be a list; only the first entry receives binaries. + first := strings.Split(env.GOPATH, string(os.PathListSeparator))[0] + if first != "" { + dirs = append(dirs, filepath.Join(first, "bin")) + } + } + if env.Home != "" { + dirs = append(dirs, filepath.Join(env.Home, "go", "bin")) + } + return dirs +} + +// normalizePath lowercases on Windows (its paths are case-insensitive) +// and converts separators to forward slashes so the marker checks above +// can be written once instead of per-OS. +func normalizePath(p, goos string) string { + if p == "" { + return "" + } + // On Windows, backslash is the path separator; convert to forward slash. + // filepath.ToSlash doesn't work for testing Windows paths on Unix hosts. + if goos == "windows" { + p = strings.ReplaceAll(p, "\\", "/") + p = strings.ToLower(p) + } else { + p = filepath.ToSlash(p) + // Marker comparisons are lowercase; on case-sensitive systems + // only the fixed markers are folded, never the user's path. + p = strings.Replace(p, "/Cellar/", "/cellar/", 1) + p = strings.Replace(p, "/scoop/", "/scoop/", 1) + } + return p +} + +// pathDir returns the parent directory of an already-normalized +// (forward-slash) path, without a trailing slash. +func pathDir(p string) string { + i := strings.LastIndex(p, "/") + if i <= 0 { + return p + } + return p[:i] +} diff --git a/internal/upgrade/detect_test.go b/internal/upgrade/detect_test.go new file mode 100644 index 0000000..5302bca --- /dev/null +++ b/internal/upgrade/detect_test.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package upgrade + +import "testing" + +func TestDetect(t *testing.T) { + cases := []struct { + name string + env Env + want Method + }{ + { + name: "homebrew macos arm", + env: Env{ExePath: "/opt/homebrew/Cellar/commitbrief/1.13.0/bin/commitbrief", GOOS: "darwin"}, + want: MethodHomebrew, + }, + { + name: "homebrew intel macos", + env: Env{ExePath: "/usr/local/Cellar/commitbrief/1.13.0/bin/commitbrief", GOOS: "darwin"}, + want: MethodHomebrew, + }, + { + name: "linuxbrew", + env: Env{ExePath: "/home/linuxbrew/.linuxbrew/Cellar/commitbrief/1.13.0/bin/commitbrief", GOOS: "linux"}, + want: MethodHomebrew, + }, + { + name: "scoop default location", + env: Env{ExePath: `C:\Users\ada\scoop\apps\commitbrief\current\commitbrief.exe`, GOOS: "windows", UserProfile: `C:\Users\ada`}, + want: MethodScoop, + }, + { + name: "scoop custom SCOOP dir", + env: Env{ExePath: `D:\tools\sc\apps\commitbrief\1.13.0\commitbrief.exe`, GOOS: "windows", Scoop: `D:\tools\sc`}, + want: MethodScoop, + }, + { + name: "go install via GOBIN", + env: Env{ExePath: "/home/ada/dev/bin/commitbrief", GOOS: "linux", GOBIN: "/home/ada/dev/bin"}, + want: MethodGoInstall, + }, + { + name: "go install via GOPATH", + env: Env{ExePath: "/home/ada/go/bin/commitbrief", GOOS: "linux", GOPATH: "/home/ada/go"}, + want: MethodGoInstall, + }, + { + name: "go install via default GOPATH under home", + env: Env{ExePath: "/home/ada/go/bin/commitbrief", GOOS: "linux", Home: "/home/ada"}, + want: MethodGoInstall, + }, + { + name: "manual tarball in usr local bin", + env: Env{ExePath: "/usr/local/bin/commitbrief", GOOS: "linux"}, + want: MethodManual, + }, + { + name: "manual tarball in home bin", + env: Env{ExePath: "/home/ada/bin/commitbrief", GOOS: "linux", Home: "/home/ada"}, + want: MethodManual, + }, + { + // A distro package is classified manual; the write-permission + // gate in Task 6 is what actually protects it. + name: "distro package looks manual", + env: Env{ExePath: "/usr/bin/commitbrief", GOOS: "linux"}, + want: MethodManual, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := Detect(c.env); got != c.want { + t.Fatalf("Detect() = %q, want %q", got, c.want) + } + }) + } +} + +func TestDetectWindowsIsCaseInsensitive(t *testing.T) { + env := Env{ExePath: `C:\Users\Ada\Scoop\Apps\CommitBrief\current\commitbrief.exe`, GOOS: "windows", UserProfile: `C:\Users\Ada`} + if got := Detect(env); got != MethodScoop { + t.Fatalf("Detect() = %q, want %q", got, MethodScoop) + } +} From a7927e0537d163634068eeb46b21707e5cfab859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sat, 25 Jul 2026 23:40:07 +0300 Subject: [PATCH 03/15] feat(upgrade): derive release asset names and parse checksums.txt --- internal/upgrade/asset.go | 59 ++++++++++++++++++++++++++++++++++ internal/upgrade/asset_test.go | 58 +++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 internal/upgrade/asset.go create mode 100644 internal/upgrade/asset_test.go diff --git a/internal/upgrade/asset.go b/internal/upgrade/asset.go new file mode 100644 index 0000000..8ab0d1a --- /dev/null +++ b/internal/upgrade/asset.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package upgrade + +import ( + "fmt" + "strings" +) + +// ChecksumsFile is the name goreleaser gives the checksum manifest +// attached to every release (.goreleaser.yaml → checksum.name_template). +const ChecksumsFile = "checksums.txt" + +// AssetName mirrors the archive name_template in .goreleaser.yaml: +// +// {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ arch }} +// +// where .Version carries no leading "v", amd64 renders as x86_64, 386 as +// i386, and Windows archives are .zip while everything else is .tar.gz. +// If that template ever changes, this function changes with it. +func AssetName(version, goos, goarch string) string { + v := strings.TrimPrefix(strings.TrimSpace(version), "v") + arch := goarch + switch goarch { + case "amd64": + arch = "x86_64" + case "386": + arch = "i386" + } + ext := ".tar.gz" + if goos == "windows" { + ext = ".zip" + } + return fmt.Sprintf("commitbrief_%s_%s_%s%s", v, goos, arch, ext) +} + +// BinaryEntryName is the archive entry holding the executable itself. +// goreleaser places it at the archive root next to LICENSE/README. +func BinaryEntryName(goos string) string { + if goos == "windows" { + return "commitbrief.exe" + } + return "commitbrief" +} + +// ParseChecksums reads a " " manifest into a +// filename → hex-sum map. A leading '*' on the filename marks binary +// mode in the sha256sum format and is not part of the name. +func ParseChecksums(data []byte) map[string]string { + sums := make(map[string]string) + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) != 2 { + continue + } + sums[strings.TrimPrefix(fields[1], "*")] = fields[0] + } + return sums +} diff --git a/internal/upgrade/asset_test.go b/internal/upgrade/asset_test.go new file mode 100644 index 0000000..6d3bb80 --- /dev/null +++ b/internal/upgrade/asset_test.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package upgrade + +import "testing" + +func TestAssetName(t *testing.T) { + cases := []struct { + version string + goos string + goarch string + want string + }{ + {"v1.15.0", "darwin", "arm64", "commitbrief_1.15.0_darwin_arm64.tar.gz"}, + {"1.15.0", "darwin", "amd64", "commitbrief_1.15.0_darwin_x86_64.tar.gz"}, + {"v1.15.0", "linux", "amd64", "commitbrief_1.15.0_linux_x86_64.tar.gz"}, + {"v1.15.0", "linux", "arm64", "commitbrief_1.15.0_linux_arm64.tar.gz"}, + {"v1.15.0", "windows", "amd64", "commitbrief_1.15.0_windows_x86_64.zip"}, + {"v2.0.0-rc.1", "linux", "arm64", "commitbrief_2.0.0-rc.1_linux_arm64.tar.gz"}, + } + for _, c := range cases { + if got := AssetName(c.version, c.goos, c.goarch); got != c.want { + t.Fatalf("AssetName(%q,%q,%q) = %q, want %q", c.version, c.goos, c.goarch, got, c.want) + } + } +} + +func TestBinaryEntryName(t *testing.T) { + if got := BinaryEntryName("linux"); got != "commitbrief" { + t.Fatalf("BinaryEntryName(linux) = %q", got) + } + if got := BinaryEntryName("windows"); got != "commitbrief.exe" { + t.Fatalf("BinaryEntryName(windows) = %q", got) + } +} + +func TestParseChecksums(t *testing.T) { + data := []byte( + "abc123 commitbrief_1.15.0_darwin_arm64.tar.gz\n" + + "def456 commitbrief_1.15.0_linux_x86_64.tar.gz\n" + + "\n" + + "789fed *commitbrief_1.15.0_windows_x86_64.zip\n") + sums := ParseChecksums(data) + if got := sums["commitbrief_1.15.0_darwin_arm64.tar.gz"]; got != "abc123" { + t.Fatalf("darwin sum = %q, want abc123", got) + } + if got := sums["commitbrief_1.15.0_linux_x86_64.tar.gz"]; got != "def456" { + t.Fatalf("linux sum = %q, want def456", got) + } + // goreleaser writes binary-mode entries with a leading '*'; the + // star belongs to the format, not to the file name. + if got := sums["commitbrief_1.15.0_windows_x86_64.zip"]; got != "789fed" { + t.Fatalf("windows sum = %q, want 789fed", got) + } + if len(sums) != 3 { + t.Fatalf("len(sums) = %d, want 3", len(sums)) + } +} From f8b221b12874505b3775e569000962319ef756f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sat, 25 Jul 2026 23:44:23 +0300 Subject: [PATCH 04/15] feat(upgrade): add the GitHub Releases client --- internal/upgrade/release.go | 134 +++++++++++++++++++++++++++++++ internal/upgrade/release_test.go | 122 ++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 internal/upgrade/release.go create mode 100644 internal/upgrade/release_test.go diff --git a/internal/upgrade/release.go b/internal/upgrade/release.go new file mode 100644 index 0000000..b47ac17 --- /dev/null +++ b/internal/upgrade/release.go @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package upgrade + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" +) + +// DefaultAPIURL is the only endpoint CommitBrief ever contacts on its +// own behalf, and only when the user runs `commitbrief upgrade` +// (ADR-0034 §D3 — there is no automatic update check). "latest" +// excludes prereleases, so -rc tags are never offered. +const DefaultAPIURL = "https://api.github.com/repos/CommitBrief/commitbrief/releases/latest" + +// ReleasesPage is shown to the user when an automated path is not +// available (no asset for their platform, unwritable target). +const ReleasesPage = "https://github.com/CommitBrief/commitbrief/releases" + +// maxDownloadBytes caps any single response body. A release archive is +// a few megabytes; the cap only exists so a malformed or hostile +// response cannot fill the disk. +const maxDownloadBytes = 200 << 20 // 200 MiB + +var ( + // ErrRateLimited is the unauthenticated GitHub API hourly cap. + ErrRateLimited = errors.New("github api rate limit exceeded") + // ErrNoRelease means the repository has no published release. + ErrNoRelease = errors.New("no published release found") +) + +// Asset is one file attached to a release. +type Asset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +// Release is the subset of the GitHub release payload we use. +type Release struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Assets []Asset `json:"assets"` +} + +// AssetByName finds an attached file by its exact name. +func (r *Release) AssetByName(name string) (Asset, bool) { + for _, a := range r.Assets { + if a.Name == name { + return a, true + } + } + return Asset{}, false +} + +// Client talks to the GitHub Releases API. APIURL is a field so tests +// can point it at an httptest server — no test ever reaches github.com. +type Client struct { + HTTP *http.Client + APIURL string + UserAgent string +} + +// NewClient returns a client that identifies itself with the running +// CommitBrief version and gives up after 30 seconds. +func NewClient(version string) *Client { + return &Client{ + HTTP: &http.Client{Timeout: 30 * time.Second}, + APIURL: DefaultAPIURL, + UserAgent: "commitbrief/" + version, + } +} + +// Latest fetches the newest published (non-prerelease) release. +func (c *Client) Latest(ctx context.Context) (*Release, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.APIURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", c.UserAgent) + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + switch { + case resp.StatusCode == http.StatusForbidden && resp.Header.Get("X-RateLimit-Remaining") == "0": + return nil, ErrRateLimited + case resp.StatusCode == http.StatusNotFound: + return nil, ErrNoRelease + case resp.StatusCode != http.StatusOK: + return nil, fmt.Errorf("github api: unexpected status %s", resp.Status) + } + + var rel Release + // The raw body is deliberately not echoed on a parse failure: an + // error page can be arbitrarily long and is never actionable. + if err := json.NewDecoder(io.LimitReader(resp.Body, maxDownloadBytes)).Decode(&rel); err != nil { + return nil, fmt.Errorf("github api: %w", err) + } + if rel.TagName == "" { + return nil, ErrNoRelease + } + return &rel, nil +} + +// Download streams url into w. Redirects are followed (GitHub sends +// release downloads to objects.githubusercontent.com). +func (c *Client) Download(ctx context.Context, url string, w io.Writer) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", c.UserAgent) + + resp, err := c.HTTP.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download %s: unexpected status %s", url, resp.Status) + } + _, err = io.Copy(w, io.LimitReader(resp.Body, maxDownloadBytes)) + return err +} diff --git a/internal/upgrade/release_test.go b/internal/upgrade/release_test.go new file mode 100644 index 0000000..529597c --- /dev/null +++ b/internal/upgrade/release_test.go @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package upgrade + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func newTestClient(t *testing.T, h http.Handler) (*Client, *httptest.Server) { + t.Helper() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + c := NewClient("v1.14.0") + c.APIURL = srv.URL + return c, srv +} + +func TestLatestParsesRelease(t *testing.T) { + body := `{ + "tag_name": "v1.15.0", + "html_url": "https://github.com/CommitBrief/commitbrief/releases/tag/v1.15.0", + "assets": [ + {"name": "commitbrief_1.15.0_linux_x86_64.tar.gz", "browser_download_url": "https://example.test/a.tar.gz"}, + {"name": "checksums.txt", "browser_download_url": "https://example.test/checksums.txt"} + ] + }` + c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("User-Agent"); got != "commitbrief/v1.14.0" { + t.Errorf("User-Agent = %q", got) + } + if got := r.Header.Get("Accept"); got != "application/vnd.github+json" { + t.Errorf("Accept = %q", got) + } + _, _ = w.Write([]byte(body)) + })) + + rel, err := c.Latest(context.Background()) + if err != nil { + t.Fatalf("Latest() error = %v", err) + } + if rel.TagName != "v1.15.0" { + t.Fatalf("TagName = %q", rel.TagName) + } + a, ok := rel.AssetByName("checksums.txt") + if !ok || a.BrowserDownloadURL != "https://example.test/checksums.txt" { + t.Fatalf("AssetByName(checksums.txt) = %+v, ok=%v", a, ok) + } + if _, ok := rel.AssetByName("nope"); ok { + t.Fatal("AssetByName(nope) should not be found") + } +} + +func TestLatestRateLimited(t *testing.T) { + c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-RateLimit-Remaining", "0") + w.WriteHeader(http.StatusForbidden) + })) + _, err := c.Latest(context.Background()) + if !errors.Is(err, ErrRateLimited) { + t.Fatalf("error = %v, want ErrRateLimited", err) + } +} + +func TestLatestNoRelease(t *testing.T) { + c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + _, err := c.Latest(context.Background()) + if !errors.Is(err, ErrNoRelease) { + t.Fatalf("error = %v, want ErrNoRelease", err) + } +} + +func TestLatestMalformedJSON(t *testing.T) { + c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("not json")) + })) + _, err := c.Latest(context.Background()) + if err == nil { + t.Fatal("Latest() error = nil, want a parse error") + } + if errors.Is(err, ErrRateLimited) || errors.Is(err, ErrNoRelease) { + t.Fatalf("error = %v, want a plain parse error", err) + } +} + +func TestLatestServerError(t *testing.T) { + c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + if _, err := c.Latest(context.Background()); err == nil { + t.Fatal("Latest() error = nil, want a status error") + } +} + +func TestDownloadWritesBody(t *testing.T) { + c, srv := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("payload")) + })) + var buf bytes.Buffer + if err := c.Download(context.Background(), srv.URL, &buf); err != nil { + t.Fatalf("Download() error = %v", err) + } + if buf.String() != "payload" { + t.Fatalf("body = %q", buf.String()) + } +} + +func TestDownloadRejectsNon200(t *testing.T) { + c, srv := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + var buf bytes.Buffer + if err := c.Download(context.Background(), srv.URL, &buf); err == nil { + t.Fatal("Download() error = nil, want a status error") + } +} From c17340e333febf2cc15a9f60a19ad8426830810b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sat, 25 Jul 2026 23:48:09 +0300 Subject: [PATCH 05/15] feat(upgrade): add atomic binary replacement for unix and windows --- internal/upgrade/replace_test.go | 50 ++++++++++++++++++++++++ internal/upgrade/replace_unix.go | 19 +++++++++ internal/upgrade/replace_windows.go | 37 ++++++++++++++++++ internal/upgrade/replace_windows_test.go | 33 ++++++++++++++++ 4 files changed, 139 insertions(+) create mode 100644 internal/upgrade/replace_test.go create mode 100644 internal/upgrade/replace_unix.go create mode 100644 internal/upgrade/replace_windows.go create mode 100644 internal/upgrade/replace_windows_test.go diff --git a/internal/upgrade/replace_test.go b/internal/upgrade/replace_test.go new file mode 100644 index 0000000..d3d41b7 --- /dev/null +++ b/internal/upgrade/replace_test.go @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package upgrade + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReplaceBinarySwapsContent(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "commitbrief") + tmp := filepath.Join(dir, ".commitbrief-new") + + if err := os.WriteFile(target, []byte("OLD"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(tmp, []byte("NEW"), 0o755); err != nil { + t.Fatal(err) + } + + if err := replaceBinary(tmp, target); err != nil { + t.Fatalf("replaceBinary() error = %v", err) + } + + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != "NEW" { + t.Fatalf("target content = %q, want NEW", got) + } + if _, err := os.Stat(tmp); !os.IsNotExist(err) { + t.Fatalf("temp file still present: %v", err) + } +} + +func TestCleanupStaleIsSafeWhenNothingToClean(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "commitbrief") + if err := os.WriteFile(target, []byte("BIN"), 0o755); err != nil { + t.Fatal(err) + } + // Must not panic and must not touch the live binary. + CleanupStale(target) + if _, err := os.Stat(target); err != nil { + t.Fatalf("target disappeared: %v", err) + } +} diff --git a/internal/upgrade/replace_unix.go b/internal/upgrade/replace_unix.go new file mode 100644 index 0000000..5b83572 --- /dev/null +++ b/internal/upgrade/replace_unix.go @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//go:build !windows + +package upgrade + +import "os" + +// replaceBinary swaps target with the prepared file at tmp. On Unix a +// rename is a single atomic operation and is legal while the target is +// executing: the running process keeps its own inode, so the swap is +// invisible to it. On failure nothing has changed, so there is no +// rollback to perform. +func replaceBinary(tmp, target string) error { + return os.Rename(tmp, target) +} + +// CleanupStale is a no-op on Unix — the rename leaves nothing behind. +func CleanupStale(target string) {} diff --git a/internal/upgrade/replace_windows.go b/internal/upgrade/replace_windows.go new file mode 100644 index 0000000..02d4309 --- /dev/null +++ b/internal/upgrade/replace_windows.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//go:build windows + +package upgrade + +import "os" + +// replaceBinary swaps target with the prepared file at tmp. Windows +// refuses to overwrite a running .exe but does allow renaming it, so +// the live binary is moved aside first. If the second rename fails the +// original is put back, leaving the installation exactly as it was. +// +// Removing the moved-aside file fails while the process is still +// running; that is expected, and CleanupStale sweeps it on the next +// upgrade. +func replaceBinary(tmp, target string) error { + old := target + ".old" + _ = os.Remove(old) + + if err := os.Rename(target, old); err != nil { + return err + } + if err := os.Rename(tmp, target); err != nil { + _ = os.Rename(old, target) // rollback + return err + } + _ = os.Remove(old) + return nil +} + +// CleanupStale removes the moved-aside binary a previous upgrade could +// not delete because it was still executing. Best effort by design: a +// failure here is never worth interrupting an upgrade over. +func CleanupStale(target string) { + _ = os.Remove(target + ".old") +} diff --git a/internal/upgrade/replace_windows_test.go b/internal/upgrade/replace_windows_test.go new file mode 100644 index 0000000..885ce2e --- /dev/null +++ b/internal/upgrade/replace_windows_test.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//go:build windows + +package upgrade + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCleanupStaleRemovesOldFile(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "commitbrief.exe") + old := target + ".old" + + if err := os.WriteFile(target, []byte("BIN"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(old, []byte("PREVIOUS"), 0o755); err != nil { + t.Fatal(err) + } + + CleanupStale(target) + + if _, err := os.Stat(old); !os.IsNotExist(err) { + t.Fatalf(".old file still present: %v", err) + } + if _, err := os.Stat(target); err != nil { + t.Fatalf("target disappeared: %v", err) + } +} From 3dbb3f8ed185e466855cc86114c7a6b26c321903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sat, 25 Jul 2026 23:57:19 +0300 Subject: [PATCH 06/15] fix(upgrade): harden windows binary replacement rollback --- internal/upgrade/replace_windows.go | 28 +++++++-- internal/upgrade/replace_windows_test.go | 76 ++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/internal/upgrade/replace_windows.go b/internal/upgrade/replace_windows.go index 02d4309..d952531 100644 --- a/internal/upgrade/replace_windows.go +++ b/internal/upgrade/replace_windows.go @@ -4,12 +4,25 @@ package upgrade -import "os" +import ( + "fmt" + "os" +) + +// osRename indirects os.Rename so the rollback path — the one branch +// that decides whether a failed upgrade leaves a working binary behind — +// can be forced in a test. Production code never reassigns it. +var osRename = os.Rename // replaceBinary swaps target with the prepared file at tmp. Windows // refuses to overwrite a running .exe but does allow renaming it, so // the live binary is moved aside first. If the second rename fails the -// original is put back, leaving the installation exactly as it was. +// original is put back. +// +// If that rollback ALSO fails, the target is left with no binary at +// all, so the returned error names both failures and points at the +// moved-aside copy — without that, a user in this state has a missing +// command and no clue that a recoverable backup is sitting next to it. // // Removing the moved-aside file fails while the process is still // running; that is expected, and CleanupStale sweeps it on the next @@ -18,11 +31,16 @@ func replaceBinary(tmp, target string) error { old := target + ".old" _ = os.Remove(old) - if err := os.Rename(target, old); err != nil { + if err := osRename(target, old); err != nil { return err } - if err := os.Rename(tmp, target); err != nil { - _ = os.Rename(old, target) // rollback + if err := osRename(tmp, target); err != nil { + if rollbackErr := osRename(old, target); rollbackErr != nil { + return fmt.Errorf( + "upgrade failed (%v) and the rollback also failed (%v); "+ + "your previous binary is still at %s — rename it back to %s to recover", + err, rollbackErr, old, target) + } return err } _ = os.Remove(old) diff --git a/internal/upgrade/replace_windows_test.go b/internal/upgrade/replace_windows_test.go index 885ce2e..22b9458 100644 --- a/internal/upgrade/replace_windows_test.go +++ b/internal/upgrade/replace_windows_test.go @@ -5,11 +5,87 @@ package upgrade import ( + "errors" "os" "path/filepath" + "strings" "testing" ) +// TestReplaceBinaryRollsBackWhenSecondRenameFails covers the branch that +// decides whether a failed upgrade leaves a working install behind. It is +// forced through osRename rather than left to chance, because the natural +// trigger (two consecutive renames failing in one directory) cannot be +// reproduced reliably. +func TestReplaceBinaryRollsBackWhenSecondRenameFails(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "commitbrief.exe") + tmp := filepath.Join(dir, ".commitbrief-new") + if err := os.WriteFile(target, []byte("OLD"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(tmp, []byte("NEW"), 0o755); err != nil { + t.Fatal(err) + } + + original := osRename + calls := 0 + osRename = func(from, to string) error { + calls++ + if calls == 2 { // the tmp → target rename + return errors.New("induced failure") + } + return original(from, to) + } + t.Cleanup(func() { osRename = original }) + + if err := replaceBinary(tmp, target); err == nil { + t.Fatal("replaceBinary() error = nil, want the induced failure") + } + + got, err := os.ReadFile(target) + if err != nil { + t.Fatalf("target missing after rollback — the install was left broken: %v", err) + } + if string(got) != "OLD" { + t.Fatalf("target content = %q, want the original OLD restored", got) + } +} + +// TestReplaceBinaryReportsFailedRollback asserts the worst case is at +// least diagnosable: when the rollback fails too, the error must name the +// backup path so the user can recover by hand. +func TestReplaceBinaryReportsFailedRollback(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "commitbrief.exe") + tmp := filepath.Join(dir, ".commitbrief-new") + if err := os.WriteFile(target, []byte("OLD"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(tmp, []byte("NEW"), 0o755); err != nil { + t.Fatal(err) + } + + original := osRename + calls := 0 + osRename = func(from, to string) error { + calls++ + if calls >= 2 { // both the swap and the rollback fail + return errors.New("induced failure") + } + return original(from, to) + } + t.Cleanup(func() { osRename = original }) + + err := replaceBinary(tmp, target) + if err == nil { + t.Fatal("replaceBinary() error = nil, want a failed-rollback error") + } + if !strings.Contains(err.Error(), target+".old") { + t.Fatalf("error must name the recovery path %q, got: %v", target+".old", err) + } +} + func TestCleanupStaleRemovesOldFile(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "commitbrief.exe") From 47e01898aff6e70562241fc0fe1d4b4ed4eca684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 26 Jul 2026 00:03:44 +0300 Subject: [PATCH 07/15] feat(upgrade): download, verify and swap the binary for manual installs --- internal/upgrade/manual.go | 221 ++++++++++++++++++++++++++++++++ internal/upgrade/manual_test.go | 219 +++++++++++++++++++++++++++++++ 2 files changed, 440 insertions(+) create mode 100644 internal/upgrade/manual.go create mode 100644 internal/upgrade/manual_test.go diff --git a/internal/upgrade/manual.go b/internal/upgrade/manual.go new file mode 100644 index 0000000..7e4a330 --- /dev/null +++ b/internal/upgrade/manual.go @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package upgrade + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" +) + +var ( + // ErrNotWritable means the directory holding the binary cannot be + // written by this user. Reported before any download happens. + ErrNotWritable = errors.New("target directory is not writable") + // ErrAssetMissing means the release has no archive for this platform. + ErrAssetMissing = errors.New("no release asset for this platform") + // ErrChecksumMismatch means the downloaded bytes did not match + // checksums.txt. Nothing is installed. + ErrChecksumMismatch = errors.New("checksum mismatch") +) + +// ManualOptions carries everything InstallManual needs. Target is the +// resolved (symlink-free) path of the running binary. +type ManualOptions struct { + Client *Client + Release *Release + Target string + GOOS string + GOARCH string +} + +// PreflightWritable reports whether the binary can be swapped. It +// probes the *directory*, not the file: replacement is a rename, and +// rename permission comes from the parent directory. This is what +// stops the manual path from touching a root-owned /usr/bin or a +// read-only /nix/store — a distro-packaged install that Detect could +// only classify as "manual". +func PreflightWritable(target string) error { + dir := filepath.Dir(target) + f, err := os.CreateTemp(dir, ".commitbrief-probe-*") + if err != nil { + return fmt.Errorf("%w: %s", ErrNotWritable, dir) + } + name := f.Name() + _ = f.Close() + _ = os.Remove(name) + return nil +} + +// InstallManual downloads the release archive for this platform, +// verifies its SHA-256 against checksums.txt, extracts just the binary, +// and swaps it into place. +// +// Trust model: the anchor is TLS to github.com. checksums.txt is not +// signed, so this detects a truncated or corrupted download, not a +// compromised release (ADR-0034 §D7). +func InstallManual(ctx context.Context, o ManualOptions) error { + assetName := AssetName(o.Release.TagName, o.GOOS, o.GOARCH) + asset, ok := o.Release.AssetByName(assetName) + if !ok { + return fmt.Errorf("%w: %s", ErrAssetMissing, assetName) + } + sumsAsset, ok := o.Release.AssetByName(ChecksumsFile) + if !ok { + return fmt.Errorf("%w: %s", ErrAssetMissing, ChecksumsFile) + } + + var sumsBuf bytes.Buffer + if err := o.Client.Download(ctx, sumsAsset.BrowserDownloadURL, &sumsBuf); err != nil { + return err + } + want := ParseChecksums(sumsBuf.Bytes())[assetName] + if want == "" { + return fmt.Errorf("%w: %s has no entry for %s", ErrChecksumMismatch, ChecksumsFile, assetName) + } + + dir := filepath.Dir(o.Target) + + // The archive lands next to the binary so the later rename stays on + // one filesystem and therefore stays atomic. + archiveFile, err := os.CreateTemp(dir, ".commitbrief-dl-*") + if err != nil { + return fmt.Errorf("%w: %s", ErrNotWritable, dir) + } + archivePath := archiveFile.Name() + defer func() { + _ = archiveFile.Close() + _ = os.Remove(archivePath) + }() + + hasher := sha256.New() + if err := o.Client.Download(ctx, asset.BrowserDownloadURL, io.MultiWriter(archiveFile, hasher)); err != nil { + return err + } + if got := hex.EncodeToString(hasher.Sum(nil)); got != want { + return fmt.Errorf("%w: %s (want %s, got %s)", ErrChecksumMismatch, assetName, want, got) + } + if _, err := archiveFile.Seek(0, io.SeekStart); err != nil { + return err + } + + binFile, err := os.CreateTemp(dir, ".commitbrief-bin-*") + if err != nil { + return fmt.Errorf("%w: %s", ErrNotWritable, dir) + } + binPath := binFile.Name() + // Removed unconditionally: on success the rename has already taken + // the file away, and Remove on a missing path is harmless. + defer func() { + _ = binFile.Close() + _ = os.Remove(binPath) + }() + + entry := BinaryEntryName(o.GOOS) + if o.GOOS == "windows" { + info, statErr := archiveFile.Stat() + if statErr != nil { + return statErr + } + err = extractZip(archiveFile, info.Size(), entry, binFile) + } else { + err = extractTarGz(archiveFile, entry, binFile) + } + if err != nil { + return err + } + if err := binFile.Close(); err != nil { + return err + } + + // Carry over the existing binary's mode instead of forcing 0755 — + // a deliberately locked-down install stays locked down. + mode := os.FileMode(0o755) + if info, statErr := os.Stat(o.Target); statErr == nil { + mode = info.Mode().Perm() + } + if err := os.Chmod(binPath, mode); err != nil { + return err + } + + return replaceBinary(binPath, o.Target) +} + +// extractTarGz writes the single entry named want into w. +func extractTarGz(r io.Reader, want string, w io.Writer) error { + gz, err := gzip.NewReader(r) + if err != nil { + return err + } + defer func() { _ = gz.Close() }() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return fmt.Errorf("archive has no %q entry", want) + } + if err != nil { + return err + } + if err := checkEntryName(hdr.Name); err != nil { + return err + } + if hdr.Name != want { + continue + } + if _, err := io.Copy(w, io.LimitReader(tr, maxDownloadBytes)); err != nil { + return err + } + return nil + } +} + +// extractZip writes the single entry named want into w. +func extractZip(r io.ReaderAt, size int64, want string, w io.Writer) error { + zr, err := zip.NewReader(r, size) + if err != nil { + return err + } + for _, f := range zr.File { + if err := checkEntryName(f.Name); err != nil { + return err + } + if f.Name != want { + continue + } + rc, err := f.Open() + if err != nil { + return err + } + defer func() { _ = rc.Close() }() + if _, err := io.Copy(w, io.LimitReader(rc, maxDownloadBytes)); err != nil { + return err + } + return nil + } + return fmt.Errorf("archive has no %q entry", want) +} + +// checkEntryName rejects absolute paths and parent-directory escapes. +// Only one known-named entry is ever extracted, so this cannot trigger +// on a well-formed release — it is a standing guard against a malformed +// or hostile archive (tar-slip / zip-slip). +func checkEntryName(name string) error { + clean := path.Clean(filepath.ToSlash(name)) + if path.IsAbs(clean) || strings.HasPrefix(clean, "../") || clean == ".." { + return fmt.Errorf("refusing unsafe archive entry %q", name) + } + return nil +} diff --git a/internal/upgrade/manual_test.go b/internal/upgrade/manual_test.go new file mode 100644 index 0000000..7657af4 --- /dev/null +++ b/internal/upgrade/manual_test.go @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package upgrade + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" +) + +// buildTarGz returns a gzip'd tar holding one root-level entry per map +// key — the same shape goreleaser produces (binary at the root). +func buildTarGz(t *testing.T, entries map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for name, content := range entries { + hdr := &tar.Header{Name: name, Mode: 0o755, Size: int64(len(content)), Typeflag: tar.TypeReg} + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func sha256Hex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +// serveRelease starts a server that hands out the archive and its +// checksum manifest, and returns a Release pointing at it. +func serveRelease(t *testing.T, tag string, archive []byte, sum string) (*Client, *Release) { + t.Helper() + assetName := AssetName(tag, runtime.GOOS, runtime.GOARCH) + mux := http.NewServeMux() + mux.HandleFunc("/"+assetName, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(archive) + }) + mux.HandleFunc("/"+ChecksumsFile, func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, "%s %s\n", sum, assetName) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + rel := &Release{ + TagName: tag, + HTMLURL: srv.URL, + Assets: []Asset{ + {Name: assetName, BrowserDownloadURL: srv.URL + "/" + assetName}, + {Name: ChecksumsFile, BrowserDownloadURL: srv.URL + "/" + ChecksumsFile}, + }, + } + return NewClient("v1.14.0"), rel +} + +func TestInstallManualReplacesBinary(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("archive fixture is tar.gz; the windows asset is a zip (covered by TestExtractZip)") + } + dir := t.TempDir() + target := filepath.Join(dir, "commitbrief") + if err := os.WriteFile(target, []byte("OLD BINARY"), 0o755); err != nil { + t.Fatal(err) + } + + archive := buildTarGz(t, map[string]string{ + BinaryEntryName(runtime.GOOS): "NEW BINARY", + "LICENSE": "GPL", + }) + client, rel := serveRelease(t, "v1.15.0", archive, sha256Hex(archive)) + + err := InstallManual(context.Background(), ManualOptions{ + Client: client, Release: rel, Target: target, + GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, + }) + if err != nil { + t.Fatalf("InstallManual() error = %v", err) + } + + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != "NEW BINARY" { + t.Fatalf("target content = %q, want NEW BINARY", got) + } + info, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o755 { + t.Fatalf("mode = %v, want 0755 preserved from the old binary", info.Mode().Perm()) + } + // No .commitbrief-* scratch files may survive a successful run. + leftovers, _ := filepath.Glob(filepath.Join(dir, ".commitbrief-*")) + if len(leftovers) != 0 { + t.Fatalf("scratch files left behind: %v", leftovers) + } +} + +func TestInstallManualRejectsChecksumMismatch(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("tar.gz fixture") + } + dir := t.TempDir() + target := filepath.Join(dir, "commitbrief") + if err := os.WriteFile(target, []byte("OLD BINARY"), 0o755); err != nil { + t.Fatal(err) + } + + archive := buildTarGz(t, map[string]string{BinaryEntryName(runtime.GOOS): "NEW BINARY"}) + // Advertise a sum for different bytes. + client, rel := serveRelease(t, "v1.15.0", archive, sha256Hex([]byte("something else"))) + + err := InstallManual(context.Background(), ManualOptions{ + Client: client, Release: rel, Target: target, + GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, + }) + if !errors.Is(err, ErrChecksumMismatch) { + t.Fatalf("error = %v, want ErrChecksumMismatch", err) + } + got, _ := os.ReadFile(target) + if string(got) != "OLD BINARY" { + t.Fatalf("target was modified despite the mismatch: %q", got) + } + leftovers, _ := filepath.Glob(filepath.Join(dir, ".commitbrief-*")) + if len(leftovers) != 0 { + t.Fatalf("scratch files left behind: %v", leftovers) + } +} + +func TestInstallManualMissingAsset(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "commitbrief") + if err := os.WriteFile(target, []byte("OLD"), 0o755); err != nil { + t.Fatal(err) + } + rel := &Release{TagName: "v1.15.0", Assets: []Asset{{Name: "unrelated.txt"}}} + + err := InstallManual(context.Background(), ManualOptions{ + Client: NewClient("v1.14.0"), Release: rel, Target: target, + GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, + }) + if !errors.Is(err, ErrAssetMissing) { + t.Fatalf("error = %v, want ErrAssetMissing", err) + } +} + +func TestPreflightWritable(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "commitbrief") + if err := os.WriteFile(target, []byte("BIN"), 0o755); err != nil { + t.Fatal(err) + } + if err := PreflightWritable(target); err != nil { + t.Fatalf("PreflightWritable() error = %v, want nil", err) + } +} + +func TestPreflightWritableFailsOnReadOnlyDir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix permission bits do not model windows ACLs") + } + if os.Geteuid() == 0 { + t.Skip("root ignores the permission bits this test relies on") + } + dir := t.TempDir() + target := filepath.Join(dir, "commitbrief") + if err := os.WriteFile(target, []byte("BIN"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) + + if err := PreflightWritable(target); !errors.Is(err, ErrNotWritable) { + t.Fatalf("error = %v, want ErrNotWritable", err) + } +} + +func TestExtractTarGzRejectsTraversal(t *testing.T) { + archive := buildTarGz(t, map[string]string{"../escape": "EVIL"}) + var out bytes.Buffer + err := extractTarGz(bytes.NewReader(archive), "../escape", &out) + if err == nil { + t.Fatal("extractTarGz() error = nil, want a rejection") + } +} + +func TestExtractTarGzEntryNotFound(t *testing.T) { + archive := buildTarGz(t, map[string]string{"LICENSE": "GPL"}) + var out bytes.Buffer + if err := extractTarGz(bytes.NewReader(archive), "commitbrief", &out); err == nil { + t.Fatal("extractTarGz() error = nil, want entry-not-found") + } +} From f975da0d09dc9db4d84fe6951a600f3f0cbb6ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 26 Jul 2026 00:16:08 +0300 Subject: [PATCH 08/15] test(upgrade): cover the zip extraction path and missing checksum entry extractZip had no test coverage even though it is the only extraction path a real Windows manual install exercises, and a stale skip comment claimed otherwise. Also add coverage for checksums.txt downloading successfully but omitting an entry for our asset, which must fail closed rather than pass silently. --- internal/upgrade/manual_test.go | 105 +++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/internal/upgrade/manual_test.go b/internal/upgrade/manual_test.go index 7657af4..5b980b3 100644 --- a/internal/upgrade/manual_test.go +++ b/internal/upgrade/manual_test.go @@ -4,6 +4,7 @@ package upgrade import ( "archive/tar" + "archive/zip" "bytes" "compress/gzip" "context" @@ -44,6 +45,27 @@ func buildTarGz(t *testing.T, entries map[string]string) []byte { return buf.Bytes() } +// buildZip returns a zip archive holding one root-level entry per map key +// — the shape goreleaser produces for the Windows asset. +func buildZip(t *testing.T, entries map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for name, content := range entries { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + func sha256Hex(b []byte) string { sum := sha256.Sum256(b) return hex.EncodeToString(sum[:]) @@ -77,7 +99,7 @@ func serveRelease(t *testing.T, tag string, archive []byte, sum string) (*Client func TestInstallManualReplacesBinary(t *testing.T) { if runtime.GOOS == "windows" { - t.Skip("archive fixture is tar.gz; the windows asset is a zip (covered by TestExtractZip)") + t.Skip("this fixture is a tar.gz; the Windows asset is a zip, covered by TestExtractZipWritesEntry") } dir := t.TempDir() target := filepath.Join(dir, "commitbrief") @@ -201,6 +223,87 @@ func TestPreflightWritableFailsOnReadOnlyDir(t *testing.T) { } } +// TestInstallManualMissingChecksumEntry covers the case where +// checksums.txt downloads fine but carries no line for our asset. That +// must fail closed — an absent entry is not a passing verification. +func TestInstallManualMissingChecksumEntry(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("tar.gz fixture; the Windows extraction path is covered by TestExtractZipWritesEntry") + } + dir := t.TempDir() + target := filepath.Join(dir, "commitbrief") + if err := os.WriteFile(target, []byte("OLD BINARY"), 0o755); err != nil { + t.Fatal(err) + } + + archive := buildTarGz(t, map[string]string{BinaryEntryName(runtime.GOOS): "NEW BINARY"}) + assetName := AssetName("v1.15.0", runtime.GOOS, runtime.GOARCH) + + mux := http.NewServeMux() + mux.HandleFunc("/"+assetName, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(archive) + }) + mux.HandleFunc("/"+ChecksumsFile, func(w http.ResponseWriter, r *http.Request) { + // A valid manifest that simply does not mention our asset. + _, _ = fmt.Fprintf(w, "%s some_other_file.tar.gz\n", sha256Hex(archive)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + rel := &Release{ + TagName: "v1.15.0", + Assets: []Asset{ + {Name: assetName, BrowserDownloadURL: srv.URL + "/" + assetName}, + {Name: ChecksumsFile, BrowserDownloadURL: srv.URL + "/" + ChecksumsFile}, + }, + } + + err := InstallManual(context.Background(), ManualOptions{ + Client: NewClient("v1.14.0"), Release: rel, Target: target, + GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, + }) + if !errors.Is(err, ErrChecksumMismatch) { + t.Fatalf("error = %v, want ErrChecksumMismatch", err) + } + got, _ := os.ReadFile(target) + if string(got) != "OLD BINARY" { + t.Fatalf("target was modified despite the missing checksum entry: %q", got) + } +} + +// TestExtractZipWritesEntry covers the Windows extraction path. Without +// it, the only code a real Windows manual install exercises would ship +// with no test at all. +func TestExtractZipWritesEntry(t *testing.T) { + archive := buildZip(t, map[string]string{ + "commitbrief.exe": "NEW BINARY", + "LICENSE": "GPL", + }) + var out bytes.Buffer + if err := extractZip(bytes.NewReader(archive), int64(len(archive)), "commitbrief.exe", &out); err != nil { + t.Fatalf("extractZip() error = %v", err) + } + if out.String() != "NEW BINARY" { + t.Fatalf("extracted content = %q, want NEW BINARY", out.String()) + } +} + +func TestExtractZipRejectsTraversal(t *testing.T) { + archive := buildZip(t, map[string]string{"../escape": "EVIL"}) + var out bytes.Buffer + if err := extractZip(bytes.NewReader(archive), int64(len(archive)), "../escape", &out); err == nil { + t.Fatal("extractZip() error = nil, want a rejection") + } +} + +func TestExtractZipEntryNotFound(t *testing.T) { + archive := buildZip(t, map[string]string{"LICENSE": "GPL"}) + var out bytes.Buffer + if err := extractZip(bytes.NewReader(archive), int64(len(archive)), "commitbrief.exe", &out); err == nil { + t.Fatal("extractZip() error = nil, want entry-not-found") + } +} + func TestExtractTarGzRejectsTraversal(t *testing.T) { archive := buildTarGz(t, map[string]string{"../escape": "EVIL"}) var out bytes.Buffer From 94a37192e3f1bc3efbfc57dff340c70612b67f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 26 Jul 2026 00:56:33 +0300 Subject: [PATCH 09/15] feat(upgrade): delegate package-managed installs to their own manager --- internal/upgrade/managed.go | 57 ++++++++++++++++++++++++++++ internal/upgrade/managed_test.go | 65 ++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 internal/upgrade/managed.go create mode 100644 internal/upgrade/managed_test.go diff --git a/internal/upgrade/managed.go b/internal/upgrade/managed.go new file mode 100644 index 0000000..63b8e3d --- /dev/null +++ b/internal/upgrade/managed.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package upgrade + +import ( + "context" + "errors" + "fmt" + "io" + "os/exec" +) + +// ModulePath is the `go install` target for CommitBrief. +const ModulePath = "github.com/CommitBrief/commitbrief/cmd/commitbrief" + +// ErrToolMissing means the package manager that owns this install is +// not on PATH, so the upgrade cannot be delegated. +var ErrToolMissing = errors.New("package manager not found on PATH") + +// Command returns the argv that upgrades a package-managed install. +// It returns nil for MethodManual, which is handled by InstallManual. +// +// Delegating rather than overwriting the file is the core decision of +// ADR-0034 §D1: replacing a brew- or scoop-owned binary desynchronizes +// the manager's metadata, and its next upgrade either conflicts or +// silently reverts the change. +func Command(m Method) []string { + switch m { + case MethodHomebrew: + return []string{"brew", "upgrade", "commitbrief"} + case MethodScoop: + return []string{"scoop", "update", "commitbrief"} + case MethodGoInstall: + return []string{"go", "install", ModulePath + "@latest"} + default: + return nil + } +} + +// Run executes argv with its output streamed straight through to the +// user. The delegated tool's output is never parsed, so an upstream +// format change cannot break CommitBrief. +// +// argv is passed to exec directly — no shell is involved, per the +// argv-not-shell rule in the engineering standards. +func Run(ctx context.Context, argv []string, out, errOut io.Writer) error { + if len(argv) == 0 { + return errors.New("upgrade: empty command") + } + if _, err := exec.LookPath(argv[0]); err != nil { + return fmt.Errorf("%w: %s", ErrToolMissing, argv[0]) + } + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + cmd.Stdout = out + cmd.Stderr = errOut + return cmd.Run() +} diff --git a/internal/upgrade/managed_test.go b/internal/upgrade/managed_test.go new file mode 100644 index 0000000..8fe5ef1 --- /dev/null +++ b/internal/upgrade/managed_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package upgrade + +import ( + "bytes" + "context" + "errors" + "reflect" + "testing" +) + +func TestCommandPerMethod(t *testing.T) { + cases := []struct { + m Method + want []string + }{ + {MethodHomebrew, []string{"brew", "upgrade", "commitbrief"}}, + {MethodScoop, []string{"scoop", "update", "commitbrief"}}, + {MethodGoInstall, []string{"go", "install", "github.com/CommitBrief/commitbrief/cmd/commitbrief@latest"}}, + {MethodManual, nil}, + } + for _, c := range cases { + if got := Command(c.m); !reflect.DeepEqual(got, c.want) { + t.Fatalf("Command(%q) = %v, want %v", c.m, got, c.want) + } + } +} + +func TestRunReportsMissingTool(t *testing.T) { + var out, errOut bytes.Buffer + err := Run(context.Background(), []string{"commitbrief-no-such-tool", "upgrade"}, &out, &errOut) + if !errors.Is(err, ErrToolMissing) { + t.Fatalf("error = %v, want ErrToolMissing", err) + } +} + +func TestRunStreamsOutput(t *testing.T) { + var out, errOut bytes.Buffer + // `go version` exists wherever the test suite itself can run. + if err := Run(context.Background(), []string{"go", "version"}, &out, &errOut); err != nil { + t.Fatalf("Run() error = %v", err) + } + if out.Len() == 0 { + t.Fatal("expected the delegated command's stdout to be streamed") + } +} + +func TestRunPropagatesFailure(t *testing.T) { + var out, errOut bytes.Buffer + err := Run(context.Background(), []string{"go", "this-is-not-a-go-subcommand"}, &out, &errOut) + if err == nil { + t.Fatal("Run() error = nil, want the delegated command's failure") + } + if errors.Is(err, ErrToolMissing) { + t.Fatalf("error = %v, want an exit failure, not ErrToolMissing", err) + } +} + +func TestRunRejectsEmptyArgv(t *testing.T) { + var out, errOut bytes.Buffer + if err := Run(context.Background(), nil, &out, &errOut); err == nil { + t.Fatal("Run(nil) error = nil, want a rejection") + } +} From 410e73ea6da41e6cf97ad09542bca63e84815edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 26 Jul 2026 01:13:27 +0300 Subject: [PATCH 10/15] feat(cli): add the upgrade command --- internal/cli/cli_test.go | 2 +- internal/cli/root.go | 1 + internal/cli/upgrade.go | 207 ++++++++++++++++++++++++++++++++++ internal/cli/upgrade_test.go | 61 ++++++++++ internal/i18n/messages.en.yml | 17 +++ internal/i18n/messages.tr.yml | 17 +++ 6 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 internal/cli/upgrade.go create mode 100644 internal/cli/upgrade_test.go diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index a7bfae3..4d14871 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -11,7 +11,7 @@ import ( func TestRootCommandHasSubcommands(t *testing.T) { root := newRootCmd() - want := []string{"cache", "commit", "compress", "config", "diff", "doctor", "dry-run", "guard", "init", "install-hook", "list", "mcp", "providers", "remote", "setup", "summary"} + want := []string{"cache", "commit", "compress", "config", "diff", "doctor", "dry-run", "guard", "init", "install-hook", "list", "mcp", "providers", "remote", "setup", "summary", "upgrade"} got := []string{} for _, c := range root.Commands() { // cobra adds `help` and `completion` automatically; filter to ours. diff --git a/internal/cli/root.go b/internal/cli/root.go index f11445f..4c20b15 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -169,6 +169,7 @@ func newRootCmd() *cobra.Command { newSummaryCmd(), newMCPCmd(), newGuardCmd(), + newUpgradeCmd(), ) return cmd } diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go new file mode 100644 index 0000000..d141284 --- /dev/null +++ b/internal/cli/upgrade.go @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "runtime" + "strings" + + "github.com/spf13/cobra" + + "github.com/CommitBrief/commitbrief/internal/ui" + "github.com/CommitBrief/commitbrief/internal/upgrade" + "github.com/CommitBrief/commitbrief/internal/version" +) + +// upgradeReport is the --json payload. It is intentionally NOT review +// schema v1 — it describes an installation, not a review, and nothing +// in the findings contract changes because of it. +type upgradeReport struct { + Current string `json:"current"` + Latest string `json:"latest"` + Method string `json:"method"` + UpdateAvailable bool `json:"update_available"` + Action string `json:"action"` +} + +func writeUpgradeJSON(w io.Writer, rep upgradeReport) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(rep) +} + +func newUpgradeCmd() *cobra.Command { + var checkOnly bool + cmd := &cobra.Command{ + Use: "upgrade", + Short: "Check for a newer CommitBrief and install it", + Long: `Checks GitHub Releases for a newer CommitBrief and installs it. + +How the binary was installed decides what happens. A Homebrew, Scoop or +'go install' install is upgraded by its own package manager, because +overwriting a manager-owned binary desynchronizes its metadata. Only a +manually installed binary (a GitHub Releases tarball) is downloaded, +SHA-256 verified against the release checksums, and replaced in place. + +Nothing is installed without confirmation, and nothing is downloaded if +the target cannot be written — CommitBrief never invokes sudo itself. + +--check reports what would happen and installs nothing. --json implies +--check: it prints a single report object and exits. + +This is the only network request CommitBrief makes on its own behalf, +and only when you run this command. There is no automatic update check +and no telemetry.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runUpgrade(cmd, checkOnly || global.json) + }, + } + cmd.Flags().BoolVar(&checkOnly, "check", false, "only report whether a newer version exists; install nothing") + return cmd +} + +func runUpgrade(cmd *cobra.Command, checkOnly bool) error { + app, err := resolveContext(false) + if err != nil { + return err + } + cat := app.Catalog + out := cmd.OutOrStdout() + msg := cmd.ErrOrStderr() // human chatter goes to stderr so --json stdout stays clean + + exe, err := upgrade.ResolveExe() + if err != nil { + return err + } + // Sweep the moved-aside binary a previous Windows upgrade could not + // delete while it was still executing. + upgrade.CleanupStale(exe) + + method := upgrade.Detect(upgrade.CurrentEnv(exe)) + + current, ok := upgrade.ParseVersion(version.Version) + if !ok { + if checkOnly { + _, _ = fmt.Fprintln(msg, cat.T("upgrade.dev_build", version.Version)) + return nil + } + return errors.New(cat.T("upgrade.dev_build", version.Version)) + } + + client := upgrade.NewClient(version.Version) + rel, err := client.Latest(cmd.Context()) + if err != nil { + switch { + case errors.Is(err, upgrade.ErrRateLimited): + return errors.New(cat.T("upgrade.err_rate_limited")) + case errors.Is(err, upgrade.ErrNoRelease): + return errors.New(cat.T("upgrade.err_no_release")) + default: + return errors.New(cat.T("upgrade.err_network", err)) + } + } + + latest, ok := upgrade.ParseVersion(rel.TagName) + if !ok { + return errors.New(cat.T("upgrade.err_no_release")) + } + + argv := upgrade.Command(method) + action := strings.Join(argv, " ") + if method == upgrade.MethodManual { + action = upgrade.AssetName(rel.TagName, runtime.GOOS, runtime.GOARCH) + } + + rep := upgradeReport{ + Current: current.String(), + Latest: latest.String(), + Method: string(method), + UpdateAvailable: current.Compare(latest) < 0, + Action: action, + } + + if !rep.UpdateAvailable { + if global.json { + rep.Action = "" + return writeUpgradeJSON(out, rep) + } + _, _ = fmt.Fprintln(msg, cat.T("upgrade.up_to_date", current.String())) + return nil + } + + if global.json { + return writeUpgradeJSON(out, rep) + } + + _, _ = fmt.Fprintln(msg, cat.T("upgrade.available", current.String(), latest.String())) + _, _ = fmt.Fprintln(msg, cat.T("upgrade.method", string(method))) + if method == upgrade.MethodManual { + _, _ = fmt.Fprintln(msg, cat.T("upgrade.action_manual", action)) + } else { + _, _ = fmt.Fprintln(msg, cat.T("upgrade.action_managed", action)) + } + + if checkOnly { + return nil + } + + // NonInteractive mirrors internal/cli/cache.go: without a TTY and + // without --yes the answer is a deterministic "no", so an unattended + // run aborts instead of hanging or half-consuming stdin. + confirmed, err := ui.Confirm(cmd.InOrStdin(), msg, cat.T("upgrade.confirm"), ui.AskOptions{ + AssumeYes: global.yes, + Interactive: ui.IsStdinTTY(os.Stdin), + NonInteractive: !ui.IsStdinTTY(os.Stdin), + Catalog: app.Catalog, + }) + if err != nil { + return err + } + if !confirmed { + _, _ = fmt.Fprintln(msg, cat.T("upgrade.cancelled")) + return nil + } + + if method == upgrade.MethodManual { + if err := upgrade.PreflightWritable(exe); err != nil { + _, _ = fmt.Fprintln(msg, cat.T("upgrade.hint_manual")) + _, _ = fmt.Fprintf(msg, " sudo %s upgrade\n", exe) + _, _ = fmt.Fprintf(msg, " %s\n", upgrade.ReleasesPage) + return errors.New(cat.T("upgrade.err_not_writable", exe)) + } + if err := upgrade.InstallManual(cmd.Context(), upgrade.ManualOptions{ + Client: client, + Release: rel, + Target: exe, + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + }); err != nil { + switch { + case errors.Is(err, upgrade.ErrChecksumMismatch): + return errors.New(cat.T("upgrade.err_checksum", action)) + case errors.Is(err, upgrade.ErrAssetMissing): + return errors.New(cat.T("upgrade.err_asset_missing", runtime.GOOS, runtime.GOARCH, upgrade.ReleasesPage)) + case errors.Is(err, upgrade.ErrNotWritable): + return errors.New(cat.T("upgrade.err_not_writable", exe)) + default: + return err + } + } + } else { + if err := upgrade.Run(cmd.Context(), argv, msg, cmd.ErrOrStderr()); err != nil { + if errors.Is(err, upgrade.ErrToolMissing) { + return errors.New(cat.T("upgrade.err_tool_missing", string(method), argv[0])) + } + return err + } + } + + _, _ = fmt.Fprintln(msg, cat.T("upgrade.success", latest.String())) + return nil +} diff --git a/internal/cli/upgrade_test.go b/internal/cli/upgrade_test.go new file mode 100644 index 0000000..e85f89e --- /dev/null +++ b/internal/cli/upgrade_test.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package cli + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func findSubcommand(root *cobra.Command, name string) *cobra.Command { + for _, c := range root.Commands() { + if c.Name() == name { + return c + } + } + return nil +} + +func TestUpgradeCommandRegistered(t *testing.T) { + root := newRootCmd() + cmd := findSubcommand(root, "upgrade") + if cmd == nil { + t.Fatal("upgrade command is not registered on root") + return + } + if cmd.Flags().Lookup("check") == nil { + t.Fatal("--check flag is missing") + } + if cmd.Args == nil { + t.Fatal("upgrade should reject positional arguments") + } +} + +func TestUpgradeCommandRejectsArgs(t *testing.T) { + cmd := newUpgradeCmd() + if err := cmd.Args(cmd, []string{"v1.2.3"}); err == nil { + t.Fatal("Args() error = nil, want a rejection of positional arguments") + } +} + +func TestUpgradeReportJSON(t *testing.T) { + rep := upgradeReport{ + Current: "v1.14.0", + Latest: "v1.15.0", + Method: "homebrew", + UpdateAvailable: true, + Action: "brew upgrade commitbrief", + } + var sb strings.Builder + if err := writeUpgradeJSON(&sb, rep); err != nil { + t.Fatalf("writeUpgradeJSON() error = %v", err) + } + out := sb.String() + for _, want := range []string{`"current": "v1.14.0"`, `"latest": "v1.15.0"`, `"method": "homebrew"`, `"update_available": true`} { + if !strings.Contains(out, want) { + t.Fatalf("JSON output missing %s:\n%s", want, out) + } + } +} diff --git a/internal/i18n/messages.en.yml b/internal/i18n/messages.en.yml index 368ba73..08ab5b5 100644 --- a/internal/i18n/messages.en.yml +++ b/internal/i18n/messages.en.yml @@ -265,3 +265,20 @@ flaky.sandbox.transient: "Sandbox rerun did not reproduce the flake: the test pa flaky.sandbox.command_invalid: "Invalid review.sandbox_command: %s" flaky.sandbox.running: "Sandbox rerun: running `%s` up to %d times per flagged test." flaky.sandbox.no_test_name: "Sandbox rerun skipped for %s:%d — could not resolve the enclosing test name." +upgrade.up_to_date: "You are already on the latest version (%s)." +upgrade.available: "A newer version is available: %s → %s" +upgrade.method: "Install method: %s" +upgrade.action_managed: "Will run: %s" +upgrade.action_manual: "Will download and install: %s" +upgrade.confirm: "Upgrade now?" +upgrade.cancelled: "Upgrade cancelled." +upgrade.success: "Upgraded to %s." +upgrade.dev_build: "This is a development build (%s); upgrade does not apply." +upgrade.err_network: "could not reach the GitHub release API: %v" +upgrade.err_rate_limited: "GitHub API rate limit reached; try again in an hour" +upgrade.err_no_release: "no published release found" +upgrade.err_asset_missing: "no release asset published for %s/%s; download it manually from %s" +upgrade.err_checksum: "checksum mismatch for %s — nothing was installed" +upgrade.err_not_writable: "cannot write to %s" +upgrade.hint_manual: "Run it with elevated privileges, or install manually:" +upgrade.err_tool_missing: "%s install detected, but %q was not found on PATH" diff --git a/internal/i18n/messages.tr.yml b/internal/i18n/messages.tr.yml index 0ac1d82..de294f6 100644 --- a/internal/i18n/messages.tr.yml +++ b/internal/i18n/messages.tr.yml @@ -264,3 +264,20 @@ flaky.sandbox.transient: "Sandbox yeniden çalıştırma kararsızlığı yenide flaky.sandbox.command_invalid: "Geçersiz review.sandbox_command: %s" flaky.sandbox.running: "Sandbox yeniden çalıştırma: `%s` her işaretlenen test için en fazla %d kez çalıştırılıyor." flaky.sandbox.no_test_name: "%s:%d için sandbox yeniden çalıştırma atlandı — kapsayan test adı çözülemedi." +upgrade.up_to_date: "Zaten en güncel sürümdesin (%s)." +upgrade.available: "Yeni bir sürüm var: %s → %s" +upgrade.method: "Kurulum yöntemi: %s" +upgrade.action_managed: "Çalıştırılacak: %s" +upgrade.action_manual: "İndirilip kurulacak: %s" +upgrade.confirm: "Şimdi güncellensin mi?" +upgrade.cancelled: "Güncelleme iptal edildi." +upgrade.success: "%s sürümüne güncellendi." +upgrade.dev_build: "Bu bir geliştirme derlemesi (%s); upgrade uygulanmaz." +upgrade.err_network: "GitHub release API'sine erişilemedi: %v" +upgrade.err_rate_limited: "GitHub API istek limiti doldu; bir saat sonra tekrar dene" +upgrade.err_no_release: "yayınlanmış release bulunamadı" +upgrade.err_asset_missing: "%s/%s için yayınlanmış release dosyası yok; %s adresinden elle indir" +upgrade.err_checksum: "%s için checksum uyuşmadı — hiçbir şey kurulmadı" +upgrade.err_not_writable: "%s yazılamıyor" +upgrade.hint_manual: "Yükseltilmiş yetkiyle çalıştır ya da elle kur:" +upgrade.err_tool_missing: "%s kurulumu tespit edildi ama %q PATH'te bulunamadı" From db009d5adc37fbe86b17000dd0408619378883be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 26 Jul 2026 01:23:19 +0300 Subject: [PATCH 11/15] fix(cli): emit a JSON report for upgrade --json on unparseable versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously a dev/unparseable build under --json fell into the dev-build branch, printed human text to stderr, and returned nil with empty stdout and exit 0 — leaving a parsing script unable to distinguish 'no update available' from 'the command did nothing'. --json now always emits a report object, even when the current version can't be parsed. --- internal/cli/upgrade.go | 11 +++++++++++ internal/cli/upgrade_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index d141284..be81c2b 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -87,6 +87,17 @@ func runUpgrade(cmd *cobra.Command, checkOnly bool) error { current, ok := upgrade.ParseVersion(version.Version) if !ok { + // --json promises a report object on every non-error run. A dev + // build must therefore still emit one: returning nil with empty + // stdout would hand a parsing script silence and exit 0, with no + // way to tell "no update" from "the command did nothing". + if global.json { + return writeUpgradeJSON(out, upgradeReport{ + Current: version.Version, + Method: string(method), + UpdateAvailable: false, + }) + } if checkOnly { _, _ = fmt.Fprintln(msg, cat.T("upgrade.dev_build", version.Version)) return nil diff --git a/internal/cli/upgrade_test.go b/internal/cli/upgrade_test.go index e85f89e..b47c1e6 100644 --- a/internal/cli/upgrade_test.go +++ b/internal/cli/upgrade_test.go @@ -59,3 +59,28 @@ func TestUpgradeReportJSON(t *testing.T) { } } } + +// TestUpgradeReportJSONDevBuild pins the shape emitted for a build whose +// version cannot be parsed. --json must still produce a report there: +// empty stdout with exit 0 gives a parsing script no way to tell "no +// update available" from "the command did nothing". +func TestUpgradeReportJSONDevBuild(t *testing.T) { + rep := upgradeReport{ + Current: "dev", + Method: "manual", + UpdateAvailable: false, + } + var sb strings.Builder + if err := writeUpgradeJSON(&sb, rep); err != nil { + t.Fatalf("writeUpgradeJSON() error = %v", err) + } + out := sb.String() + if strings.TrimSpace(out) == "" { + t.Fatal("dev-build report produced empty output") + } + for _, want := range []string{`"current": "dev"`, `"method": "manual"`, `"update_available": false`} { + if !strings.Contains(out, want) { + t.Fatalf("JSON output missing %s:\n%s", want, out) + } + } +} From 562936857d68c6217056332a9321b293958d8d20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 26 Jul 2026 01:34:56 +0300 Subject: [PATCH 12/15] docs: document the upgrade command --- CHANGELOG.md | 14 ++ README.md | 19 +++ man/commitbrief-cache-clear.1 | 24 +++- man/commitbrief-cache-inspect.1 | 24 +++- man/commitbrief-cache-prune.1 | 24 +++- man/commitbrief-cache-stats.1 | 24 +++- man/commitbrief-cache.1 | 24 +++- man/commitbrief-commit.1 | 24 +++- man/commitbrief-completion-bash.1 | 24 +++- man/commitbrief-completion-fish.1 | 24 +++- man/commitbrief-completion-powershell.1 | 24 +++- man/commitbrief-completion-zsh.1 | 24 +++- man/commitbrief-completion.1 | 24 +++- man/commitbrief-compress.1 | 24 +++- man/commitbrief-config-get.1 | 24 +++- man/commitbrief-config-set.1 | 24 +++- man/commitbrief-config-show.1 | 24 +++- man/commitbrief-config.1 | 24 +++- man/commitbrief-diff.1 | 24 +++- man/commitbrief-doctor.1 | 24 +++- man/commitbrief-dry-run.1 | 24 +++- man/commitbrief-guard.1 | 158 +++++++++++++++++++++++ man/commitbrief-init.1 | 24 +++- man/commitbrief-install-hook.1 | 24 +++- man/commitbrief-list.1 | 24 +++- man/commitbrief-mcp.1 | 142 +++++++++++++++++++++ man/commitbrief-providers-list.1 | 24 +++- man/commitbrief-providers-test.1 | 24 +++- man/commitbrief-providers-use.1 | 24 +++- man/commitbrief-providers.1 | 24 +++- man/commitbrief-remote-pr.1 | 24 +++- man/commitbrief-remote.1 | 24 +++- man/commitbrief-setup.1 | 24 +++- man/commitbrief-summary.1 | 24 +++- man/commitbrief-upgrade.1 | 163 ++++++++++++++++++++++++ man/commitbrief.1 | 26 +++- 36 files changed, 1117 insertions(+), 125 deletions(-) create mode 100644 man/commitbrief-guard.1 create mode 100644 man/commitbrief-mcp.1 create mode 100644 man/commitbrief-upgrade.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 611db93..450a751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,20 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v ## [Unreleased] +### Added +- **`commitbrief upgrade` — in-tool updates across every install method (ADR-0034).** + Detects whether the running binary came from Homebrew, Scoop, `go install` + or a GitHub Releases tarball. Package-managed installs are delegated to + their own manager (`brew upgrade` / `scoop update` / `go install …@latest`) + because overwriting a manager-owned binary desynchronizes its metadata; + only a manual install is replaced in place, after its SHA-256 is verified + against the release `checksums.txt`. An unwritable target aborts *before* + anything is downloaded and prints the exact command to run — CommitBrief + never invokes `sudo` itself. `--check` reports without installing and + always exits 0; `--json` implies `--check`. + The version check runs **only** when you invoke the command: there is no + automatic update check and no telemetry. + ## [1.14.0] - 2026-07-25 ### Added diff --git a/README.md b/README.md index d0ab021..a1f3fba 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,24 @@ Pre-built binaries for Linux, macOS, and Windows on amd64 and arm64 are attached to each tagged release at [github.com/CommitBrief/commitbrief/releases](https://github.com/CommitBrief/commitbrief/releases). +### Upgrade + +```sh +commitbrief upgrade # check, confirm, install +commitbrief upgrade --check # report only; install nothing +``` + +`upgrade` detects how the binary was installed and does the right thing for +it: Homebrew, Scoop and `go install` are handed to their own package +manager, while a manually installed binary is downloaded from GitHub +Releases, SHA-256 verified against the release checksums, and swapped in +place. If the binary's directory is not writable, nothing is downloaded and +the exact command you need is printed — CommitBrief never runs `sudo` +itself. Only the binary is replaced; bundled man pages are not installed. + +This is the only network request CommitBrief makes on its own behalf, and +only when you run this command. There is no automatic update check. + ## Stability The v1.0.0 line is an **API freeze**. CLI flag surface, the JSON @@ -222,6 +240,7 @@ commitbrief init [--force] # write COMMITBRIEF.md + OUTPUT.md commitbrief compress [--level=balanced] [--dry-run] # shrink COMMITBRIEF.md (preview first if you want) commitbrief doctor # health-check the pipeline commitbrief install-hook [--hook=...] # install a git hook that runs commitbrief +commitbrief upgrade [--check] # check GitHub Releases and install a newer CommitBrief commitbrief dry-run # pipeline preview; no API call commitbrief list # command reference commitbrief mcp # run an MCP server over stdio (agent review gate; see "MCP server") diff --git a/man/commitbrief-cache-clear.1 b/man/commitbrief-cache-clear.1 index a109a99..69bf549 100644 --- a/man/commitbrief-cache-clear.1 +++ b/man/commitbrief-cache-clear.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-cache-clear - Remove cached LLM responses for this repo @@ -40,7 +40,7 @@ Remove cached LLM responses for this repo .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -48,7 +48,7 @@ Remove cached LLM responses for this repo .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -70,6 +70,14 @@ Remove cached LLM responses for this repo \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -94,6 +102,10 @@ Remove cached LLM responses for this repo \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -102,6 +114,10 @@ Remove cached LLM responses for this repo \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -120,4 +136,4 @@ Remove cached LLM responses for this repo .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-cache-inspect.1 b/man/commitbrief-cache-inspect.1 index 280dedc..7ba0e25 100644 --- a/man/commitbrief-cache-inspect.1 +++ b/man/commitbrief-cache-inspect.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-cache-inspect - Show metadata for a single cache entry by key @@ -44,7 +44,7 @@ Dumps one cached entry's metadata (provider, model, language, timestamps, freshn .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -52,7 +52,7 @@ Dumps one cached entry's metadata (provider, model, language, timestamps, freshn .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -74,6 +74,14 @@ Dumps one cached entry's metadata (provider, model, language, timestamps, freshn \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -98,6 +106,10 @@ Dumps one cached entry's metadata (provider, model, language, timestamps, freshn \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -106,6 +118,10 @@ Dumps one cached entry's metadata (provider, model, language, timestamps, freshn \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -124,4 +140,4 @@ Dumps one cached entry's metadata (provider, model, language, timestamps, freshn .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-cache-prune.1 b/man/commitbrief-cache-prune.1 index ceb9dca..6c4174d 100644 --- a/man/commitbrief-cache-prune.1 +++ b/man/commitbrief-cache-prune.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-cache-prune - Drop old/excess cache entries (keep newest N + entries within age window) @@ -56,7 +56,7 @@ Without flags, defaults to \fB--keep-last 500 --older-than 7d\fR\&. Entries surv .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -64,7 +64,7 @@ Without flags, defaults to \fB--keep-last 500 --older-than 7d\fR\&. Entries surv .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -82,6 +82,14 @@ Without flags, defaults to \fB--keep-last 500 --older-than 7d\fR\&. Entries surv \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -102,6 +110,10 @@ Without flags, defaults to \fB--keep-last 500 --older-than 7d\fR\&. Entries surv \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -110,6 +122,10 @@ Without flags, defaults to \fB--keep-last 500 --older-than 7d\fR\&. Entries surv \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -128,4 +144,4 @@ Without flags, defaults to \fB--keep-last 500 --older-than 7d\fR\&. Entries surv .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-cache-stats.1 b/man/commitbrief-cache-stats.1 index a035143..34162ce 100644 --- a/man/commitbrief-cache-stats.1 +++ b/man/commitbrief-cache-stats.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-cache-stats - Show cache entry count, size, age range, and per-provider breakdown @@ -40,7 +40,7 @@ Summarizes the repo-local response cache at /.commitbrief/cache/: total entries .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -48,7 +48,7 @@ Summarizes the repo-local response cache at /.commitbrief/cache/: total entries .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -70,6 +70,14 @@ Summarizes the repo-local response cache at /.commitbrief/cache/: total entries \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -94,6 +102,10 @@ Summarizes the repo-local response cache at /.commitbrief/cache/: total entries \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -102,6 +114,10 @@ Summarizes the repo-local response cache at /.commitbrief/cache/: total entries \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -120,4 +136,4 @@ Summarizes the repo-local response cache at /.commitbrief/cache/: total entries .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-cache.1 b/man/commitbrief-cache.1 index 6c8237e..7591fa8 100644 --- a/man/commitbrief-cache.1 +++ b/man/commitbrief-cache.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-cache - Inspect and manage the local response cache @@ -40,7 +40,7 @@ Inspect and manage the local response cache .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -48,7 +48,7 @@ Inspect and manage the local response cache .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -70,6 +70,14 @@ Inspect and manage the local response cache \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -94,6 +102,10 @@ Inspect and manage the local response cache \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -102,6 +114,10 @@ Inspect and manage the local response cache \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -120,4 +136,4 @@ Inspect and manage the local response cache .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-commit.1 b/man/commitbrief-commit.1 index 006c314..159b9ac 100644 --- a/man/commitbrief-commit.1 +++ b/man/commitbrief-commit.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-commit - Generate a commit message from staged changes and commit @@ -54,7 +54,7 @@ Needs an interactive terminal to confirm (or to pick from --generate alternative .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -62,7 +62,7 @@ Needs an interactive terminal to confirm (or to pick from --generate alternative .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -84,6 +84,14 @@ Needs an interactive terminal to confirm (or to pick from --generate alternative \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -108,6 +116,10 @@ Needs an interactive terminal to confirm (or to pick from --generate alternative \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -116,6 +128,10 @@ Needs an interactive terminal to confirm (or to pick from --generate alternative \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -134,4 +150,4 @@ Needs an interactive terminal to confirm (or to pick from --generate alternative .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-completion-bash.1 b/man/commitbrief-completion-bash.1 index 45b9df5..b4b4d50 100644 --- a/man/commitbrief-completion-bash.1 +++ b/man/commitbrief-completion-bash.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-completion-bash - Generate the autocompletion script for bash @@ -71,7 +71,7 @@ You will need to start a new shell for this setup to take effect. .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -79,7 +79,7 @@ You will need to start a new shell for this setup to take effect. .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -101,6 +101,14 @@ You will need to start a new shell for this setup to take effect. \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -125,6 +133,10 @@ You will need to start a new shell for this setup to take effect. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -133,6 +145,10 @@ You will need to start a new shell for this setup to take effect. \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -151,4 +167,4 @@ You will need to start a new shell for this setup to take effect. .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-completion-fish.1 b/man/commitbrief-completion-fish.1 index 8e1767c..79a7763 100644 --- a/man/commitbrief-completion-fish.1 +++ b/man/commitbrief-completion-fish.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-completion-fish - Generate the autocompletion script for fish @@ -61,7 +61,7 @@ You will need to start a new shell for this setup to take effect. .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -69,7 +69,7 @@ You will need to start a new shell for this setup to take effect. .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -91,6 +91,14 @@ You will need to start a new shell for this setup to take effect. \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -115,6 +123,10 @@ You will need to start a new shell for this setup to take effect. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -123,6 +135,10 @@ You will need to start a new shell for this setup to take effect. \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -141,4 +157,4 @@ You will need to start a new shell for this setup to take effect. .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-completion-powershell.1 b/man/commitbrief-completion-powershell.1 index be0f13a..e781921 100644 --- a/man/commitbrief-completion-powershell.1 +++ b/man/commitbrief-completion-powershell.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-completion-powershell - Generate the autocompletion script for powershell @@ -55,7 +55,7 @@ to your powershell profile. .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -63,7 +63,7 @@ to your powershell profile. .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -85,6 +85,14 @@ to your powershell profile. \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -109,6 +117,10 @@ to your powershell profile. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -117,6 +129,10 @@ to your powershell profile. \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -135,4 +151,4 @@ to your powershell profile. .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-completion-zsh.1 b/man/commitbrief-completion-zsh.1 index d68d866..a2c9672 100644 --- a/man/commitbrief-completion-zsh.1 +++ b/man/commitbrief-completion-zsh.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-completion-zsh - Generate the autocompletion script for zsh @@ -75,7 +75,7 @@ You will need to start a new shell for this setup to take effect. .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -83,7 +83,7 @@ You will need to start a new shell for this setup to take effect. .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -105,6 +105,14 @@ You will need to start a new shell for this setup to take effect. \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -129,6 +137,10 @@ You will need to start a new shell for this setup to take effect. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -137,6 +149,10 @@ You will need to start a new shell for this setup to take effect. \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -155,4 +171,4 @@ You will need to start a new shell for this setup to take effect. .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-completion.1 b/man/commitbrief-completion.1 index c82047f..dd6551f 100644 --- a/man/commitbrief-completion.1 +++ b/man/commitbrief-completion.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-completion - Generate the autocompletion script for the specified shell @@ -41,7 +41,7 @@ See each sub-command's help for details on how to use the generated script. .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -49,7 +49,7 @@ See each sub-command's help for details on how to use the generated script. .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -71,6 +71,14 @@ See each sub-command's help for details on how to use the generated script. \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -95,6 +103,10 @@ See each sub-command's help for details on how to use the generated script. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -103,6 +115,10 @@ See each sub-command's help for details on how to use the generated script. \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -121,4 +137,4 @@ See each sub-command's help for details on how to use the generated script. .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-compress.1 b/man/commitbrief-compress.1 index 67c83a2..88e2692 100644 --- a/man/commitbrief-compress.1 +++ b/man/commitbrief-compress.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-compress - Shrink COMMITBRIEF.md losslessly via the configured provider @@ -57,7 +57,7 @@ an ISO timestamp before the file is replaced. .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -65,7 +65,7 @@ an ISO timestamp before the file is replaced. .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -87,6 +87,14 @@ an ISO timestamp before the file is replaced. \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -111,6 +119,10 @@ an ISO timestamp before the file is replaced. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -119,6 +131,10 @@ an ISO timestamp before the file is replaced. \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -137,4 +153,4 @@ an ISO timestamp before the file is replaced. .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-config-get.1 b/man/commitbrief-config-get.1 index 4f0bf55..8c58c5f 100644 --- a/man/commitbrief-config-get.1 +++ b/man/commitbrief-config-get.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-config-get - Print a single configuration value by dotted path @@ -47,7 +47,7 @@ Examples: .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -55,7 +55,7 @@ Examples: .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -77,6 +77,14 @@ Examples: \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -101,6 +109,10 @@ Examples: \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -109,6 +121,10 @@ Examples: \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -127,4 +143,4 @@ Examples: .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-config-set.1 b/man/commitbrief-config-set.1 index 7378207..dc084e3 100644 --- a/man/commitbrief-config-set.1 +++ b/man/commitbrief-config-set.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-config-set - Write a single configuration value by dotted path @@ -55,7 +55,7 @@ By default writes to ~/.commitbrief/config.yml; --local writes to the repo. .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -63,7 +63,7 @@ By default writes to ~/.commitbrief/config.yml; --local writes to the repo. .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -85,6 +85,14 @@ By default writes to ~/.commitbrief/config.yml; --local writes to the repo. \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -109,6 +117,10 @@ By default writes to ~/.commitbrief/config.yml; --local writes to the repo. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -117,6 +129,10 @@ By default writes to ~/.commitbrief/config.yml; --local writes to the repo. \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -135,4 +151,4 @@ By default writes to ~/.commitbrief/config.yml; --local writes to the repo. .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-config-show.1 b/man/commitbrief-config-show.1 index b780e93..5b276f3 100644 --- a/man/commitbrief-config-show.1 +++ b/man/commitbrief-config-show.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-config-show - Print the merged configuration (API keys masked) @@ -40,7 +40,7 @@ Print the merged configuration (API keys masked) .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -48,7 +48,7 @@ Print the merged configuration (API keys masked) .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -70,6 +70,14 @@ Print the merged configuration (API keys masked) \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -94,6 +102,10 @@ Print the merged configuration (API keys masked) \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -102,6 +114,10 @@ Print the merged configuration (API keys masked) \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -120,4 +136,4 @@ Print the merged configuration (API keys masked) .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-config.1 b/man/commitbrief-config.1 index 5eed80c..60be57c 100644 --- a/man/commitbrief-config.1 +++ b/man/commitbrief-config.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-config - Show, get, or set individual configuration values @@ -40,7 +40,7 @@ Show, get, or set individual configuration values .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -48,7 +48,7 @@ Show, get, or set individual configuration values .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -70,6 +70,14 @@ Show, get, or set individual configuration values \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -94,6 +102,10 @@ Show, get, or set individual configuration values \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -102,6 +114,10 @@ Show, get, or set individual configuration values \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -120,4 +136,4 @@ Show, get, or set individual configuration values .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-diff.1 b/man/commitbrief-diff.1 index a0552cd..8d9a7a1 100644 --- a/man/commitbrief-diff.1 +++ b/man/commitbrief-diff.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-diff - Run a review against an arbitrary git diff (passthrough) @@ -40,7 +40,7 @@ Review the output of \fBgit diff \fR\&. Arguments are forwarded verbatim t .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -48,7 +48,7 @@ Review the output of \fBgit diff \fR\&. Arguments are forwarded verbatim t .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -70,6 +70,14 @@ Review the output of \fBgit diff \fR\&. Arguments are forwarded verbatim t \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -94,6 +102,10 @@ Review the output of \fBgit diff \fR\&. Arguments are forwarded verbatim t \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -102,6 +114,10 @@ Review the output of \fBgit diff \fR\&. Arguments are forwarded verbatim t \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -120,4 +136,4 @@ Review the output of \fBgit diff \fR\&. Arguments are forwarded verbatim t .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-doctor.1 b/man/commitbrief-doctor.1 index 0857288..512578b 100644 --- a/man/commitbrief-doctor.1 +++ b/man/commitbrief-doctor.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-doctor - Run a health check across the configured pipeline @@ -52,7 +52,7 @@ run produces no output. .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -60,7 +60,7 @@ run produces no output. .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -82,6 +82,14 @@ run produces no output. \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -102,6 +110,10 @@ run produces no output. \fB--provider\fP="" override configured provider +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -110,6 +122,10 @@ run produces no output. \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -128,4 +144,4 @@ run produces no output. .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-dry-run.1 b/man/commitbrief-dry-run.1 index 27d350e..01a2fcc 100644 --- a/man/commitbrief-dry-run.1 +++ b/man/commitbrief-dry-run.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-dry-run - Build prompt and report what would be sent; no API call @@ -48,7 +48,7 @@ Build prompt and report what would be sent; no API call .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -56,7 +56,7 @@ Build prompt and report what would be sent; no API call .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -78,6 +78,14 @@ Build prompt and report what would be sent; no API call \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -102,6 +110,10 @@ Build prompt and report what would be sent; no API call \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -110,6 +122,10 @@ Build prompt and report what would be sent; no API call \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -128,4 +144,4 @@ Build prompt and report what would be sent; no API call .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-guard.1 b/man/commitbrief-guard.1 new file mode 100644 index 0000000..0461d21 --- /dev/null +++ b/man/commitbrief-guard.1 @@ -0,0 +1,158 @@ +.nh +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" + +.SH NAME +commitbrief-guard - Gate a review against a declarative policy (.commitbrief/policy.yml) + + +.SH SYNOPSIS +\fBcommitbrief guard [flags]\fP + + +.SH DESCRIPTION +Evaluate a review's actionable findings against a declarative policy and exit non-zero when it is breached — a richer, opt-in alternative to --fail-on for gating (often AI-authored) pull requests. + +.PP +The policy (.commitbrief/policy.yml) caps how many findings of each severity a change may carry (plus an optional total cap). Unlike --fail-on (a single threshold), guard enforces a per-severity budget and can consume a prior review's JSON via --from-json without re-running the provider. It evaluates the set that survives baseline + suppression. + + +.SH OPTIONS +\fB--diff\fP=[] + run-mode: review an arbitrary \fBgit diff\fR range (e.g. main...HEAD); repeatable + +.PP +\fB--from-json\fP="" + evaluate a prior schema-v1 review JSON (a file path, or - for stdin) instead of running a review + +.PP +\fB-h\fP, \fB--help\fP[=false] + help for guard + +.PP +\fB--policy\fP=".commitbrief/policy.yml" + path to the policy file + +.PP +\fB--unstaged\fP[=false] + run-mode: review the unstaged working tree instead of the staged index + + +.SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB--allow-secrets\fP[=false] + bypass the pre-send secret scanner (use with care) + +.PP +\fB--cli\fP="" + use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli + +.PP +\fB--color\fP="auto" + color output: auto, always, never + +.PP +\fB--compact\fP[=false] + one-line per finding (dense review output) + +.PP +\fB--copy\fP[=false] + copy findings (severity, path, title, description) to the system clipboard via OSC 52 + native tool + +.PP +\fB-d\fP, \fB--dir\fP=[] + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag + +.PP +\fB--fail-on\fP="" + exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) + +.PP +\fB-f\fP, \fB--file\fP=[] + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag + +.PP +\fB--json\fP[=false] + emit machine-readable JSON output + +.PP +\fB--lang\fP="" + AI output language (e.g. tr, fr); the CLI interface localizes for en/tr only, output for any recognized language. Resolution: --lang → repo config → user config → English + +.PP +\fB--markdown\fP[=false] + emit plain markdown (no ANSI) + +.PP +\fB--min-severity\fP="" + hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set + +.PP +\fB--model\fP="" + override configured model + +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + +.PP +\fB--no-cache\fP[=false] + bypass cache (read and write) + +.PP +\fB--no-cost-check\fP[=false] + skip the pre-send cost estimate prompt + +.PP +\fB--no-flaky\fP[=false] + skip the deterministic flaky-test detector (ADR-0022) + +.PP +\fB-o\fP, \fB--output\fP="" + write output to file instead of stdout + +.PP +\fB--provider\fP="" + override configured provider + +.PP +\fB-q\fP, \fB--quiet\fP[=false] + suppress info messages on stderr + +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + +.PP +\fB--suggest-commit\fP[=false] + after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) + +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + +.PP +\fB-v\fP, \fB--verbose\fP[=false] + show token/cost/latency footer + +.PP +\fB--with-context\fP[=false] + let the CLI provider read project files beyond the diff to ground the review (CLI providers only; the host CLI's agent reads your repo — see --help) + +.PP +\fB-y\fP, \fB--yes\fP[=false] + auto-confirm prompts (pre-send guard, init overwrite) + + +.SH SEE ALSO +\fBcommitbrief(1)\fP + + +.SH HISTORY +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-init.1 b/man/commitbrief-init.1 index db5a6c5..132c71e 100644 --- a/man/commitbrief-init.1 +++ b/man/commitbrief-init.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-init - Write COMMITBRIEF.md and a per-user OUTPUT.md template @@ -53,7 +53,7 @@ to overwrite the existing file(s) too. .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -61,7 +61,7 @@ to overwrite the existing file(s) too. .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -83,6 +83,14 @@ to overwrite the existing file(s) too. \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -107,6 +115,10 @@ to overwrite the existing file(s) too. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -115,6 +127,10 @@ to overwrite the existing file(s) too. \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -133,4 +149,4 @@ to overwrite the existing file(s) too. .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-install-hook.1 b/man/commitbrief-install-hook.1 index 31777ef..6de5d9d 100644 --- a/man/commitbrief-install-hook.1 +++ b/man/commitbrief-install-hook.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-install-hook - Install (or uninstall) a git hook that runs commitbrief on commit @@ -72,7 +72,7 @@ comment). Refuses to touch a hook that doesn't carry our marker. .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -80,7 +80,7 @@ comment). Refuses to touch a hook that doesn't carry our marker. .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -102,6 +102,14 @@ comment). Refuses to touch a hook that doesn't carry our marker. \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -126,6 +134,10 @@ comment). Refuses to touch a hook that doesn't carry our marker. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -134,6 +146,10 @@ comment). Refuses to touch a hook that doesn't carry our marker. \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -152,4 +168,4 @@ comment). Refuses to touch a hook that doesn't carry our marker. .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-list.1 b/man/commitbrief-list.1 index 1413ceb..366a798 100644 --- a/man/commitbrief-list.1 +++ b/man/commitbrief-list.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-list - Print the command reference @@ -40,7 +40,7 @@ Print the command reference .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -48,7 +48,7 @@ Print the command reference .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -70,6 +70,14 @@ Print the command reference \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -94,6 +102,10 @@ Print the command reference \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -102,6 +114,10 @@ Print the command reference \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -120,4 +136,4 @@ Print the command reference .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-mcp.1 b/man/commitbrief-mcp.1 new file mode 100644 index 0000000..cc972f9 --- /dev/null +++ b/man/commitbrief-mcp.1 @@ -0,0 +1,142 @@ +.nh +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" + +.SH NAME +commitbrief-mcp - Run an MCP (Model Context Protocol) server over stdio + + +.SH SYNOPSIS +\fBcommitbrief mcp [flags]\fP + + +.SH DESCRIPTION +Expose CommitBrief to an AI agent/host as an MCP tool. The server speaks JSON-RPC 2.0 over stdio (the MCP stdio transport) and offers a \fBreview\fR tool that runs the standard review pipeline on the current repo's diff and returns the structured findings (JSON schema v1). + +.PP +Wire it into a host (e.g. Claude Desktop / an agent runtime) as a stdio MCP server invoking \fBcommitbrief mcp\fR\&. The host owns the lifecycle; this process reads requests on stdin and writes responses on stdout until the host closes the stream. Diagnostics are written to stderr. + + +.SH OPTIONS +\fB-h\fP, \fB--help\fP[=false] + help for mcp + + +.SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB--allow-secrets\fP[=false] + bypass the pre-send secret scanner (use with care) + +.PP +\fB--cli\fP="" + use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli + +.PP +\fB--color\fP="auto" + color output: auto, always, never + +.PP +\fB--compact\fP[=false] + one-line per finding (dense review output) + +.PP +\fB--copy\fP[=false] + copy findings (severity, path, title, description) to the system clipboard via OSC 52 + native tool + +.PP +\fB-d\fP, \fB--dir\fP=[] + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag + +.PP +\fB--fail-on\fP="" + exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) + +.PP +\fB-f\fP, \fB--file\fP=[] + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag + +.PP +\fB--json\fP[=false] + emit machine-readable JSON output + +.PP +\fB--lang\fP="" + AI output language (e.g. tr, fr); the CLI interface localizes for en/tr only, output for any recognized language. Resolution: --lang → repo config → user config → English + +.PP +\fB--markdown\fP[=false] + emit plain markdown (no ANSI) + +.PP +\fB--min-severity\fP="" + hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set + +.PP +\fB--model\fP="" + override configured model + +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + +.PP +\fB--no-cache\fP[=false] + bypass cache (read and write) + +.PP +\fB--no-cost-check\fP[=false] + skip the pre-send cost estimate prompt + +.PP +\fB--no-flaky\fP[=false] + skip the deterministic flaky-test detector (ADR-0022) + +.PP +\fB-o\fP, \fB--output\fP="" + write output to file instead of stdout + +.PP +\fB--provider\fP="" + override configured provider + +.PP +\fB-q\fP, \fB--quiet\fP[=false] + suppress info messages on stderr + +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + +.PP +\fB--suggest-commit\fP[=false] + after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) + +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + +.PP +\fB-v\fP, \fB--verbose\fP[=false] + show token/cost/latency footer + +.PP +\fB--with-context\fP[=false] + let the CLI provider read project files beyond the diff to ground the review (CLI providers only; the host CLI's agent reads your repo — see --help) + +.PP +\fB-y\fP, \fB--yes\fP[=false] + auto-confirm prompts (pre-send guard, init overwrite) + + +.SH SEE ALSO +\fBcommitbrief(1)\fP + + +.SH HISTORY +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-providers-list.1 b/man/commitbrief-providers-list.1 index 65bca9a..a16a4f1 100644 --- a/man/commitbrief-providers-list.1 +++ b/man/commitbrief-providers-list.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-providers-list - Show configured providers (active marker, model, API key status) @@ -40,7 +40,7 @@ Show configured providers (active marker, model, API key status) .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -48,7 +48,7 @@ Show configured providers (active marker, model, API key status) .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -70,6 +70,14 @@ Show configured providers (active marker, model, API key status) \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -94,6 +102,10 @@ Show configured providers (active marker, model, API key status) \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -102,6 +114,10 @@ Show configured providers (active marker, model, API key status) \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -120,4 +136,4 @@ Show configured providers (active marker, model, API key status) .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-providers-test.1 b/man/commitbrief-providers-test.1 index 63d0c6b..6b9a9d3 100644 --- a/man/commitbrief-providers-test.1 +++ b/man/commitbrief-providers-test.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-providers-test - Ping a configured provider to verify the API key and reachability @@ -40,7 +40,7 @@ Ping a configured provider to verify the API key and reachability .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -48,7 +48,7 @@ Ping a configured provider to verify the API key and reachability .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -70,6 +70,14 @@ Ping a configured provider to verify the API key and reachability \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -94,6 +102,10 @@ Ping a configured provider to verify the API key and reachability \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -102,6 +114,10 @@ Ping a configured provider to verify the API key and reachability \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -120,4 +136,4 @@ Ping a configured provider to verify the API key and reachability .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-providers-use.1 b/man/commitbrief-providers-use.1 index df1eb16..aa9c59e 100644 --- a/man/commitbrief-providers-use.1 +++ b/man/commitbrief-providers-use.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-providers-use - Switch the active default provider (no API keys changed) @@ -44,7 +44,7 @@ Switch the active default provider (no API keys changed) .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -52,7 +52,7 @@ Switch the active default provider (no API keys changed) .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -74,6 +74,14 @@ Switch the active default provider (no API keys changed) \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -98,6 +106,10 @@ Switch the active default provider (no API keys changed) \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -106,6 +118,10 @@ Switch the active default provider (no API keys changed) \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -124,4 +140,4 @@ Switch the active default provider (no API keys changed) .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-providers.1 b/man/commitbrief-providers.1 index efe7137..3f6e801 100644 --- a/man/commitbrief-providers.1 +++ b/man/commitbrief-providers.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-providers - List, switch, and test configured LLM providers @@ -40,7 +40,7 @@ List, switch, and test configured LLM providers .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -48,7 +48,7 @@ List, switch, and test configured LLM providers .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -70,6 +70,14 @@ List, switch, and test configured LLM providers \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -94,6 +102,10 @@ List, switch, and test configured LLM providers \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -102,6 +114,10 @@ List, switch, and test configured LLM providers \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -120,4 +136,4 @@ List, switch, and test configured LLM providers .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-remote-pr.1 b/man/commitbrief-remote-pr.1 index 4203934..8e8c8b1 100644 --- a/man/commitbrief-remote-pr.1 +++ b/man/commitbrief-remote-pr.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-remote-pr - Review a GitHub pull request and post findings as inline comments @@ -57,7 +57,7 @@ See ADR-0016. .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -65,7 +65,7 @@ See ADR-0016. .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -87,6 +87,14 @@ See ADR-0016. \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -111,6 +119,10 @@ See ADR-0016. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -119,6 +131,10 @@ See ADR-0016. \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -137,4 +153,4 @@ See ADR-0016. .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-remote.1 b/man/commitbrief-remote.1 index 82d8b13..72e1e5b 100644 --- a/man/commitbrief-remote.1 +++ b/man/commitbrief-remote.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-remote - Drive GitHub operations (PR review) through the gh CLI @@ -44,7 +44,7 @@ they don't produce structured findings). .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -52,7 +52,7 @@ they don't produce structured findings). .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -74,6 +74,14 @@ they don't produce structured findings). \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -98,6 +106,10 @@ they don't produce structured findings). \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -106,6 +118,10 @@ they don't produce structured findings). \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -124,4 +140,4 @@ they don't produce structured findings). .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-setup.1 b/man/commitbrief-setup.1 index d8bee8f..2f64a00 100644 --- a/man/commitbrief-setup.1 +++ b/man/commitbrief-setup.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-setup - Interactive provider + API key wizard @@ -62,7 +62,7 @@ the chosen name already shadows a command on your PATH you are warned first. .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -70,7 +70,7 @@ the chosen name already shadows a command on your PATH you are warned first. .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -92,6 +92,14 @@ the chosen name already shadows a command on your PATH you are warned first. \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -116,6 +124,10 @@ the chosen name already shadows a command on your PATH you are warned first. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -124,6 +136,10 @@ the chosen name already shadows a command on your PATH you are warned first. \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -142,4 +158,4 @@ the chosen name already shadows a command on your PATH you are warned first. .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-summary.1 b/man/commitbrief-summary.1 index 0957cb1..fecdd7c 100644 --- a/man/commitbrief-summary.1 +++ b/man/commitbrief-summary.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief-summary - Summarize a set of changes in plain language (read-only) @@ -54,7 +54,7 @@ Output is plain text; use -o/--output to write it to a file. The pre-send guard, .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -62,7 +62,7 @@ Output is plain text; use -o/--output to write it to a file. The pre-send guard, .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB--json\fP[=false] @@ -84,6 +84,14 @@ Output is plain text; use -o/--output to write it to a file. The pre-send guard, \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -108,6 +116,10 @@ Output is plain text; use -o/--output to write it to a file. The pre-send guard, \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -116,6 +128,10 @@ Output is plain text; use -o/--output to write it to a file. The pre-send guard, \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -134,4 +150,4 @@ Output is plain text; use -o/--output to write it to a file. The pre-send guard, .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief-upgrade.1 b/man/commitbrief-upgrade.1 new file mode 100644 index 0000000..f46e290 --- /dev/null +++ b/man/commitbrief-upgrade.1 @@ -0,0 +1,163 @@ +.nh +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" + +.SH NAME +commitbrief-upgrade - Check for a newer CommitBrief and install it + + +.SH SYNOPSIS +\fBcommitbrief upgrade [flags]\fP + + +.SH DESCRIPTION +Checks GitHub Releases for a newer CommitBrief and installs it. + +.PP +How the binary was installed decides what happens. A Homebrew, Scoop or +\&'go install' install is upgraded by its own package manager, because +overwriting a manager-owned binary desynchronizes its metadata. Only a +manually installed binary (a GitHub Releases tarball) is downloaded, +SHA-256 verified against the release checksums, and replaced in place. + +.PP +Nothing is installed without confirmation, and nothing is downloaded if +the target cannot be written — CommitBrief never invokes sudo itself. + +.PP +--check reports what would happen and installs nothing. --json implies +--check: it prints a single report object and exits. + +.PP +This is the only network request CommitBrief makes on its own behalf, +and only when you run this command. There is no automatic update check +and no telemetry. + + +.SH OPTIONS +\fB--check\fP[=false] + only report whether a newer version exists; install nothing + +.PP +\fB-h\fP, \fB--help\fP[=false] + help for upgrade + + +.SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB--allow-secrets\fP[=false] + bypass the pre-send secret scanner (use with care) + +.PP +\fB--cli\fP="" + use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli + +.PP +\fB--color\fP="auto" + color output: auto, always, never + +.PP +\fB--compact\fP[=false] + one-line per finding (dense review output) + +.PP +\fB--copy\fP[=false] + copy findings (severity, path, title, description) to the system clipboard via OSC 52 + native tool + +.PP +\fB-d\fP, \fB--dir\fP=[] + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag + +.PP +\fB--fail-on\fP="" + exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) + +.PP +\fB-f\fP, \fB--file\fP=[] + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag + +.PP +\fB--json\fP[=false] + emit machine-readable JSON output + +.PP +\fB--lang\fP="" + AI output language (e.g. tr, fr); the CLI interface localizes for en/tr only, output for any recognized language. Resolution: --lang → repo config → user config → English + +.PP +\fB--markdown\fP[=false] + emit plain markdown (no ANSI) + +.PP +\fB--min-severity\fP="" + hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set + +.PP +\fB--model\fP="" + override configured model + +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + +.PP +\fB--no-cache\fP[=false] + bypass cache (read and write) + +.PP +\fB--no-cost-check\fP[=false] + skip the pre-send cost estimate prompt + +.PP +\fB--no-flaky\fP[=false] + skip the deterministic flaky-test detector (ADR-0022) + +.PP +\fB-o\fP, \fB--output\fP="" + write output to file instead of stdout + +.PP +\fB--provider\fP="" + override configured provider + +.PP +\fB-q\fP, \fB--quiet\fP[=false] + suppress info messages on stderr + +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + +.PP +\fB--suggest-commit\fP[=false] + after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) + +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + +.PP +\fB-v\fP, \fB--verbose\fP[=false] + show token/cost/latency footer + +.PP +\fB--with-context\fP[=false] + let the CLI provider read project files beyond the diff to ground the review (CLI providers only; the host CLI's agent reads your repo — see --help) + +.PP +\fB-y\fP, \fB--yes\fP[=false] + auto-confirm prompts (pre-send guard, init overwrite) + + +.SH SEE ALSO +\fBcommitbrief(1)\fP + + +.SH HISTORY +26-Jul-2026 Auto generated by spf13/cobra diff --git a/man/commitbrief.1 b/man/commitbrief.1 index 43255eb..57b6f47 100644 --- a/man/commitbrief.1 +++ b/man/commitbrief.1 @@ -1,5 +1,5 @@ .nh -.TH "COMMITBRIEF" "1" "Jun 2026" "Auto generated by spf13/cobra" "" +.TH "COMMITBRIEF" "1" "Jul 2026" "Auto generated by spf13/cobra" "" .SH NAME commitbrief - Local LLM-powered code review of git diffs @@ -35,7 +35,7 @@ Local LLM-powered code review of git diffs .PP \fB-d\fP, \fB--dir\fP=[] - review only files under these directories (repeatable); combines with the active scope flag + review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag .PP \fB--fail-on\fP="" @@ -43,7 +43,7 @@ Local LLM-powered code review of git diffs .PP \fB-f\fP, \fB--file\fP=[] - review only these files (repeatable); combines with the active scope flag + review only these files or globs (e.g. \fB*.go\fR, \fBinternal/**/*.ts\fR; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag .PP \fB-h\fP, \fB--help\fP[=false] @@ -69,6 +69,14 @@ Local LLM-powered code review of git diffs \fB--model\fP="" override configured model +.PP +\fB--no-architecture\fP[=false] + skip architecture-aware review (do not read architecture.json into the prompt) for this run (ADR-0030) + +.PP +\fB--no-baseline\fP[=false] + ignore the signal-control baseline for this run (show everything, even baselined findings) + .PP \fB--no-cache\fP[=false] bypass cache (read and write) @@ -93,6 +101,10 @@ Local LLM-powered code review of git diffs \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--sandbox-rerun\fP[=0] + confirm flagged flaky tests by re-running each in isolation N times (ADR-0022); mixed pass+fail = confirmed flaky, all-fail = real failure, all-pass = transient. 0 = off; bare --sandbox-rerun uses N=5. Requires a bound rerun executor + .PP \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) @@ -109,6 +121,10 @@ Local LLM-powered code review of git diffs \fB-u\fP, \fB--unstaged\fP[=false] review unstaged changes +.PP +\fB--update-baseline\fP[=false] + rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) + .PP \fB-v\fP, \fB--verbose\fP[=false] show token/cost/latency footer @@ -127,8 +143,8 @@ Local LLM-powered code review of git diffs .SH SEE ALSO -\fBcommitbrief-cache(1)\fP, \fBcommitbrief-commit(1)\fP, \fBcommitbrief-completion(1)\fP, \fBcommitbrief-compress(1)\fP, \fBcommitbrief-config(1)\fP, \fBcommitbrief-diff(1)\fP, \fBcommitbrief-doctor(1)\fP, \fBcommitbrief-dry-run(1)\fP, \fBcommitbrief-init(1)\fP, \fBcommitbrief-install-hook(1)\fP, \fBcommitbrief-list(1)\fP, \fBcommitbrief-providers(1)\fP, \fBcommitbrief-remote(1)\fP, \fBcommitbrief-setup(1)\fP, \fBcommitbrief-summary(1)\fP +\fBcommitbrief-cache(1)\fP, \fBcommitbrief-commit(1)\fP, \fBcommitbrief-completion(1)\fP, \fBcommitbrief-compress(1)\fP, \fBcommitbrief-config(1)\fP, \fBcommitbrief-diff(1)\fP, \fBcommitbrief-doctor(1)\fP, \fBcommitbrief-dry-run(1)\fP, \fBcommitbrief-guard(1)\fP, \fBcommitbrief-init(1)\fP, \fBcommitbrief-install-hook(1)\fP, \fBcommitbrief-list(1)\fP, \fBcommitbrief-mcp(1)\fP, \fBcommitbrief-providers(1)\fP, \fBcommitbrief-remote(1)\fP, \fBcommitbrief-setup(1)\fP, \fBcommitbrief-summary(1)\fP, \fBcommitbrief-upgrade(1)\fP .SH HISTORY -19-Jun-2026 Auto generated by spf13/cobra +26-Jul-2026 Auto generated by spf13/cobra From 8e82824782ae1dc5e1b927f6dc3535125693da67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 26 Jul 2026 01:45:45 +0300 Subject: [PATCH 13/15] fix(cli): verify a manual upgrade's binary reports the new version After a manual replacement, nothing re-ran the new binary to confirm the swap took effect. If another commitbrief comes earlier on the user's PATH, the file swap succeeds but the command they type is still the old binary, and 'Upgraded to vX.Y.Z' would report success regardless. verifyReplacement re-runs --version and warns (never fails) on a mismatch; manual path only, since a package manager may relocate its own binary. reportsVersion trims the tag's leading v before comparing, since goreleaser injects the version without one. --- internal/cli/upgrade.go | 33 +++++++++++++++++++++++++++++++++ internal/cli/upgrade_test.go | 28 ++++++++++++++++++++++++++++ internal/i18n/messages.en.yml | 2 ++ internal/i18n/messages.tr.yml | 2 ++ 4 files changed, 65 insertions(+) diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index be81c2b..3220baf 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "os" + "os/exec" "runtime" "strings" @@ -204,6 +205,7 @@ func runUpgrade(cmd *cobra.Command, checkOnly bool) error { return err } } + verifyReplacement(cmd, cat, msg, exe, latest) } else { if err := upgrade.Run(cmd.Context(), argv, msg, cmd.ErrOrStderr()); err != nil { if errors.Is(err, upgrade.ErrToolMissing) { @@ -216,3 +218,34 @@ func runUpgrade(cmd *cobra.Command, checkOnly bool) error { _, _ = fmt.Fprintln(msg, cat.T("upgrade.success", latest.String())) return nil } + +// verifyReplacement re-runs the binary we just swapped and warns when it +// does not report the expected version. The usual cause is another +// commitbrief earlier on PATH shadowing this one: the swap genuinely +// succeeded, but the command the user types is still the old binary, and +// a silent success would leave them believing otherwise. +// +// Never fatal — the upgrade already happened, and a failure to re-exec +// (a sandbox, a hardened mount) is not a reason to report failure. +// Applies to the manual path only; a package manager may relocate its +// binary, so `exe` is not necessarily the new file after delegation. +func verifyReplacement(cmd *cobra.Command, cat catalog, msg io.Writer, exe string, latest upgrade.Version) { + out, err := exec.CommandContext(cmd.Context(), exe, "--version").Output() + if err != nil { + _, _ = fmt.Fprintln(msg, cat.T("upgrade.verify_failed", err)) + return + } + reported := strings.TrimSpace(string(out)) + if !reportsVersion(reported, latest) { + _, _ = fmt.Fprintln(msg, cat.T("upgrade.verify_mismatch", reported)) + } +} + +// reportsVersion checks whether --version output names the expected +// release. The leading "v" is trimmed because goreleaser injects the tag +// without it (`-X …version.Version={{.Version}}`), so a released binary +// prints "commitbrief 1.15.0 (…)" while the tag reads "v1.15.0". +// Comparing them verbatim would warn on every successful upgrade. +func reportsVersion(output string, v upgrade.Version) bool { + return strings.Contains(output, strings.TrimPrefix(v.String(), "v")) +} diff --git a/internal/cli/upgrade_test.go b/internal/cli/upgrade_test.go index b47c1e6..4452f32 100644 --- a/internal/cli/upgrade_test.go +++ b/internal/cli/upgrade_test.go @@ -7,6 +7,8 @@ import ( "testing" "github.com/spf13/cobra" + + "github.com/CommitBrief/commitbrief/internal/upgrade" ) func findSubcommand(root *cobra.Command, name string) *cobra.Command { @@ -60,6 +62,32 @@ func TestUpgradeReportJSON(t *testing.T) { } } +// TestReportsVersion pins the tag-vs-binary version comparison. The +// goreleaser-shaped case is the one that matters: the binary prints its +// version WITHOUT a leading "v", so a verbatim comparison against the tag +// would warn on every successful upgrade. +func TestReportsVersion(t *testing.T) { + v, ok := upgrade.ParseVersion("v1.15.0") + if !ok { + t.Fatal("ParseVersion failed") + } + cases := []struct { + output string + want bool + }{ + {"commitbrief 1.15.0 (commit abc1234, built 2026-07-26)", true}, + {"commitbrief v1.15.0 (commit abc1234, built 2026-07-26)", true}, + {"commitbrief 1.14.0 (commit abc1234, built 2026-07-04)", false}, + {"commitbrief dev (commit none, built unknown)", false}, + {"", false}, + } + for _, c := range cases { + if got := reportsVersion(c.output, v); got != c.want { + t.Fatalf("reportsVersion(%q) = %v, want %v", c.output, got, c.want) + } + } +} + // TestUpgradeReportJSONDevBuild pins the shape emitted for a build whose // version cannot be parsed. --json must still produce a report there: // empty stdout with exit 0 gives a parsing script no way to tell "no diff --git a/internal/i18n/messages.en.yml b/internal/i18n/messages.en.yml index 08ab5b5..125b90f 100644 --- a/internal/i18n/messages.en.yml +++ b/internal/i18n/messages.en.yml @@ -282,3 +282,5 @@ upgrade.err_checksum: "checksum mismatch for %s — nothing was installed" upgrade.err_not_writable: "cannot write to %s" upgrade.hint_manual: "Run it with elevated privileges, or install manually:" upgrade.err_tool_missing: "%s install detected, but %q was not found on PATH" +upgrade.verify_failed: "warning: could not re-run the upgraded binary to verify it: %v" +upgrade.verify_mismatch: "warning: the upgraded binary reports %q — another commitbrief may come earlier on your PATH" diff --git a/internal/i18n/messages.tr.yml b/internal/i18n/messages.tr.yml index de294f6..ccf5b9d 100644 --- a/internal/i18n/messages.tr.yml +++ b/internal/i18n/messages.tr.yml @@ -281,3 +281,5 @@ upgrade.err_checksum: "%s için checksum uyuşmadı — hiçbir şey kurulmadı" upgrade.err_not_writable: "%s yazılamıyor" upgrade.hint_manual: "Yükseltilmiş yetkiyle çalıştır ya da elle kur:" upgrade.err_tool_missing: "%s kurulumu tespit edildi ama %q PATH'te bulunamadı" +upgrade.verify_failed: "uyarı: güncellenen binary doğrulama için çalıştırılamadı: %v" +upgrade.verify_mismatch: "uyarı: güncellenen binary %q bildiriyor — PATH'te önce gelen başka bir commitbrief olabilir" From 439124efe72b503f1c6d79ccd63ab35efc2fe081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 26 Jul 2026 02:14:28 +0300 Subject: [PATCH 14/15] fix(upgrade): correct timeout, verify, cleanup, and error-mapping bugs Final review fix wave on the upgrade command before merge: - Download used the 30s whole-request API client timeout, which counts time spent reading the body; a slow connection on a ~12 MiB archive failed permanently. Downloads now use a separate client with no whole-request timeout, bounded by a 30s ResponseHeaderTimeout instead. The API client's own timeout is restored to the originally specified 10s. - verifyReplacement's mismatch warning blamed PATH shadowing, but it execs the resolved binary directly and cannot observe PATH. Split into two independent checks: a version-mismatch warning that says only what it knows, and a new PATH-shadow check (LookPath + EvalSymlinks) that can actually detect a shadowing commitbrief and names both paths. - CleanupStale only swept the Windows .old file; interrupted manual installs left .commitbrief-dl-*/.commitbrief-bin-* scratch files behind forever. It's now shared across platforms and also sweeps those temps. - --check skipped the write-permission gate on the manual path, so it could report an install that a real run would refuse; it now runs the same preflight and warns without failing. - A malformed API response was reported as a network failure; it now maps to a distinct ErrBadResponse sentinel and message. - Fixed a tautological mode-preservation test and removed a dead no-op string replacement in path normalization. Docs (.ssot, wiki) corrected to match the accurate two-check verify rationale and the --check permission-gate behavior. --- internal/cli/upgrade.go | 75 ++++++++++++++++++++++++----- internal/cli/upgrade_test.go | 47 ++++++++++++++++++ internal/i18n/messages.en.yml | 4 +- internal/i18n/messages.tr.yml | 4 +- internal/upgrade/cleanup.go | 37 ++++++++++++++ internal/upgrade/detect.go | 1 - internal/upgrade/manual_test.go | 10 ++-- internal/upgrade/release.go | 34 +++++++++++-- internal/upgrade/release_test.go | 36 +++++++++++++- internal/upgrade/replace_test.go | 32 ++++++++++++ internal/upgrade/replace_unix.go | 6 ++- internal/upgrade/replace_windows.go | 7 +-- 12 files changed, 262 insertions(+), 31 deletions(-) create mode 100644 internal/upgrade/cleanup.go diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index 3220baf..5843378 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -9,6 +9,7 @@ import ( "io" "os" "os/exec" + "path/filepath" "runtime" "strings" @@ -114,6 +115,11 @@ func runUpgrade(cmd *cobra.Command, checkOnly bool) error { return errors.New(cat.T("upgrade.err_rate_limited")) case errors.Is(err, upgrade.ErrNoRelease): return errors.New(cat.T("upgrade.err_no_release")) + case errors.Is(err, upgrade.ErrBadResponse): + // The server was reached and answered; the payload just + // didn't parse. That is distinct from a network failure and + // must not be reported as one. + return errors.New(cat.T("upgrade.err_bad_response", err)) default: return errors.New(cat.T("upgrade.err_network", err)) } @@ -160,6 +166,20 @@ func runUpgrade(cmd *cobra.Command, checkOnly bool) error { } if checkOnly { + // Surface the same permission gate a real run would hit, so a + // root-owned or read-only manual install is reported honestly + // instead of promising an install that would actually be + // refused. Never reached when --json implied checkOnly: that + // path already returned above, and --json prints only the + // report object. A warning only — --check always exits 0. + if method == upgrade.MethodManual { + if err := upgrade.PreflightWritable(exe); err != nil { + _, _ = fmt.Fprintln(msg, cat.T("upgrade.err_not_writable", exe)) + _, _ = fmt.Fprintln(msg, cat.T("upgrade.hint_manual")) + _, _ = fmt.Fprintf(msg, " sudo %s upgrade\n", exe) + _, _ = fmt.Fprintf(msg, " %s\n", upgrade.ReleasesPage) + } + } return nil } @@ -219,26 +239,55 @@ func runUpgrade(cmd *cobra.Command, checkOnly bool) error { return nil } -// verifyReplacement re-runs the binary we just swapped and warns when it -// does not report the expected version. The usual cause is another -// commitbrief earlier on PATH shadowing this one: the swap genuinely -// succeeded, but the command the user types is still the old binary, and -// a silent success would leave them believing otherwise. +// verifyReplacement re-runs the binary we just swapped and checks two +// independent things, each with its own warning: whether it reports the +// expected version, and whether some other "commitbrief" resolves +// earlier on PATH than the file that was just upgraded. Neither implies +// the other — a version mismatch after execing the resolved `exe` +// directly cannot be explained by PATH shadowing, since exec bypasses +// PATH entirely; the shadow check exists to catch the separate, real +// problem that the command the user types next may still resolve to +// the old binary even though this exact file was upgraded correctly. // -// Never fatal — the upgrade already happened, and a failure to re-exec -// (a sandbox, a hardened mount) is not a reason to report failure. -// Applies to the manual path only; a package manager may relocate its -// binary, so `exe` is not necessarily the new file after delegation. +// Never fatal — the upgrade already happened, and neither a failure to +// re-exec (a sandbox, a hardened mount) nor a shadowing PATH entry is a +// reason to report failure. Applies to the manual path only; a package +// manager may relocate its binary, so `exe` is not necessarily the new +// file after delegation. func verifyReplacement(cmd *cobra.Command, cat catalog, msg io.Writer, exe string, latest upgrade.Version) { out, err := exec.CommandContext(cmd.Context(), exe, "--version").Output() if err != nil { _, _ = fmt.Fprintln(msg, cat.T("upgrade.verify_failed", err)) - return + } else if reported := strings.TrimSpace(string(out)); !reportsVersion(reported, latest) { + _, _ = fmt.Fprintln(msg, cat.T("upgrade.verify_mismatch", reported, latest.String())) + } + + if shadow, ok := shadowingPath(exe); ok { + _, _ = fmt.Fprintln(msg, cat.T("upgrade.verify_shadowed", exe, shadow)) + } +} + +// shadowingPath reports whatever "commitbrief" the user's shell would +// actually resolve on PATH, if it differs from exe (the binary just +// upgraded). ok is false when PATH has no commitbrief, or it resolves +// to exe itself — neither is worth reporting, and a LookPath failure is +// not an error in its own right, just the common case of a manual +// install that was never put on PATH. +func shadowingPath(exe string) (shadow string, ok bool) { + found, err := exec.LookPath("commitbrief") + if err != nil { + return "", false + } + resolved, err := filepath.EvalSymlinks(found) + if err != nil { + // A broken or unreadable link is not fatal to the check — + // compare what LookPath found, unresolved. + resolved = found } - reported := strings.TrimSpace(string(out)) - if !reportsVersion(reported, latest) { - _, _ = fmt.Fprintln(msg, cat.T("upgrade.verify_mismatch", reported)) + if resolved == exe { + return "", false } + return resolved, true } // reportsVersion checks whether --version output names the expected diff --git a/internal/cli/upgrade_test.go b/internal/cli/upgrade_test.go index 4452f32..ca96c4f 100644 --- a/internal/cli/upgrade_test.go +++ b/internal/cli/upgrade_test.go @@ -3,6 +3,9 @@ package cli import ( + "os" + "path/filepath" + "runtime" "strings" "testing" @@ -88,6 +91,50 @@ func TestReportsVersion(t *testing.T) { } } +// TestShadowingPath pins the three outcomes verifyReplacement's shadow +// warning depends on: nothing on PATH, PATH resolving to a different +// binary than the one just upgraded, and PATH resolving to that same +// binary (the common, unremarkable case — no warning). +func TestShadowingPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PATH/executable-bit resolution differs on windows") + } + dir := t.TempDir() + bin := filepath.Join(dir, "commitbrief") + if err := os.WriteFile(bin, []byte("#!/bin/sh\necho ok\n"), 0o755); err != nil { + t.Fatal(err) + } + resolved, err := filepath.EvalSymlinks(bin) + if err != nil { + t.Fatal(err) + } + + t.Run("nothing on PATH", func(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + if _, ok := shadowingPath("/wherever/commitbrief"); ok { + t.Fatal("shadowingPath() ok = true, want false when PATH has no commitbrief") + } + }) + + t.Run("resolves to a different binary", func(t *testing.T) { + t.Setenv("PATH", dir) + shadow, ok := shadowingPath("/somewhere/else/commitbrief") + if !ok { + t.Fatal("shadowingPath() ok = false, want true") + } + if shadow != resolved { + t.Fatalf("shadowingPath() = %q, want %q", shadow, resolved) + } + }) + + t.Run("resolves to the same binary", func(t *testing.T) { + t.Setenv("PATH", dir) + if _, ok := shadowingPath(resolved); ok { + t.Fatal("shadowingPath() ok = true, want false when PATH resolves to exe itself") + } + }) +} + // TestUpgradeReportJSONDevBuild pins the shape emitted for a build whose // version cannot be parsed. --json must still produce a report there: // empty stdout with exit 0 gives a parsing script no way to tell "no diff --git a/internal/i18n/messages.en.yml b/internal/i18n/messages.en.yml index 125b90f..b9fe3af 100644 --- a/internal/i18n/messages.en.yml +++ b/internal/i18n/messages.en.yml @@ -275,6 +275,7 @@ upgrade.cancelled: "Upgrade cancelled." upgrade.success: "Upgraded to %s." upgrade.dev_build: "This is a development build (%s); upgrade does not apply." upgrade.err_network: "could not reach the GitHub release API: %v" +upgrade.err_bad_response: "the GitHub release API returned a response that could not be understood: %v" upgrade.err_rate_limited: "GitHub API rate limit reached; try again in an hour" upgrade.err_no_release: "no published release found" upgrade.err_asset_missing: "no release asset published for %s/%s; download it manually from %s" @@ -283,4 +284,5 @@ upgrade.err_not_writable: "cannot write to %s" upgrade.hint_manual: "Run it with elevated privileges, or install manually:" upgrade.err_tool_missing: "%s install detected, but %q was not found on PATH" upgrade.verify_failed: "warning: could not re-run the upgraded binary to verify it: %v" -upgrade.verify_mismatch: "warning: the upgraded binary reports %q — another commitbrief may come earlier on your PATH" +upgrade.verify_mismatch: "warning: the upgraded binary reports %q, not the expected %q" +upgrade.verify_shadowed: "warning: %q was upgraded, but %q comes first on PATH and will run instead" diff --git a/internal/i18n/messages.tr.yml b/internal/i18n/messages.tr.yml index ccf5b9d..0daccbb 100644 --- a/internal/i18n/messages.tr.yml +++ b/internal/i18n/messages.tr.yml @@ -274,6 +274,7 @@ upgrade.cancelled: "Güncelleme iptal edildi." upgrade.success: "%s sürümüne güncellendi." upgrade.dev_build: "Bu bir geliştirme derlemesi (%s); upgrade uygulanmaz." upgrade.err_network: "GitHub release API'sine erişilemedi: %v" +upgrade.err_bad_response: "GitHub release API'si anlaşılamayan bir yanıt döndürdü: %v" upgrade.err_rate_limited: "GitHub API istek limiti doldu; bir saat sonra tekrar dene" upgrade.err_no_release: "yayınlanmış release bulunamadı" upgrade.err_asset_missing: "%s/%s için yayınlanmış release dosyası yok; %s adresinden elle indir" @@ -282,4 +283,5 @@ upgrade.err_not_writable: "%s yazılamıyor" upgrade.hint_manual: "Yükseltilmiş yetkiyle çalıştır ya da elle kur:" upgrade.err_tool_missing: "%s kurulumu tespit edildi ama %q PATH'te bulunamadı" upgrade.verify_failed: "uyarı: güncellenen binary doğrulama için çalıştırılamadı: %v" -upgrade.verify_mismatch: "uyarı: güncellenen binary %q bildiriyor — PATH'te önce gelen başka bir commitbrief olabilir" +upgrade.verify_mismatch: "uyarı: güncellenen binary %q bildiriyor, beklenen %q değil" +upgrade.verify_shadowed: "uyarı: %q güncellendi, ama PATH'te önce %q geliyor ve onun yerine o çalışacak" diff --git a/internal/upgrade/cleanup.go b/internal/upgrade/cleanup.go new file mode 100644 index 0000000..33836c1 --- /dev/null +++ b/internal/upgrade/cleanup.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package upgrade + +import ( + "os" + "path/filepath" +) + +// CleanupStale removes scratch files a previous, interrupted upgrade +// could not clean up itself. A normal InstallManual run's defers remove +// its own ".commitbrief-dl-*" (downloaded archive) and +// ".commitbrief-bin-*" (extracted binary) temp files, but Ctrl-C kills +// the process before any defer runs, so an interrupted upgrade leaves +// one of them — up to ~12 MiB — sitting next to the target binary +// forever, until the next `upgrade` sweeps it here. +// +// A concurrent upgrade's in-flight temp file could in principle be +// swept out from under it by this same glob. That upgrade simply fails +// on its next read or write of the now-missing file; it never gets far +// enough to touch the installed binary, so the loser of that race fails +// safely rather than corrupting anything. +// +// Also runs cleanupOld, the platform-specific half of this sweep: the +// moved-aside ".old" binary a previous Windows upgrade could not +// delete while it was still executing (a no-op on Unix, where the +// rename leaves nothing behind). +func CleanupStale(target string) { + dir := filepath.Dir(target) + for _, pattern := range []string{".commitbrief-dl-*", ".commitbrief-bin-*"} { + matches, _ := filepath.Glob(filepath.Join(dir, pattern)) + for _, m := range matches { + _ = os.Remove(m) + } + } + cleanupOld(target) +} diff --git a/internal/upgrade/detect.go b/internal/upgrade/detect.go index 96376b7..8b91fba 100644 --- a/internal/upgrade/detect.go +++ b/internal/upgrade/detect.go @@ -133,7 +133,6 @@ func normalizePath(p, goos string) string { // Marker comparisons are lowercase; on case-sensitive systems // only the fixed markers are folded, never the user's path. p = strings.Replace(p, "/Cellar/", "/cellar/", 1) - p = strings.Replace(p, "/scoop/", "/scoop/", 1) } return p } diff --git a/internal/upgrade/manual_test.go b/internal/upgrade/manual_test.go index 5b980b3..30798b4 100644 --- a/internal/upgrade/manual_test.go +++ b/internal/upgrade/manual_test.go @@ -103,7 +103,11 @@ func TestInstallManualReplacesBinary(t *testing.T) { } dir := t.TempDir() target := filepath.Join(dir, "commitbrief") - if err := os.WriteFile(target, []byte("OLD BINARY"), 0o755); err != nil { + // A non-default mode: manual.go falls back to a hardcoded 0755 when + // it cannot stat the existing target, so asserting 0755 here would + // pass even if the actual preservation logic were deleted. 0700 + // makes the assertion mean something. + if err := os.WriteFile(target, []byte("OLD BINARY"), 0o700); err != nil { t.Fatal(err) } @@ -132,8 +136,8 @@ func TestInstallManualReplacesBinary(t *testing.T) { if err != nil { t.Fatal(err) } - if info.Mode().Perm() != 0o755 { - t.Fatalf("mode = %v, want 0755 preserved from the old binary", info.Mode().Perm()) + if info.Mode().Perm() != 0o700 { + t.Fatalf("mode = %v, want 0700 preserved from the old binary", info.Mode().Perm()) } // No .commitbrief-* scratch files may survive a successful run. leftovers, _ := filepath.Glob(filepath.Join(dir, ".commitbrief-*")) diff --git a/internal/upgrade/release.go b/internal/upgrade/release.go index b47ac17..5a092d4 100644 --- a/internal/upgrade/release.go +++ b/internal/upgrade/release.go @@ -32,6 +32,11 @@ var ( ErrRateLimited = errors.New("github api rate limit exceeded") // ErrNoRelease means the repository has no published release. ErrNoRelease = errors.New("no published release found") + // ErrBadResponse means the GitHub API answered — the server was + // reached, and returned a 200 — but the body did not decode as the + // expected JSON shape. Distinct from a network failure: the request + // itself succeeded, only the payload was unusable. + ErrBadResponse = errors.New("could not parse the github release response") ) // Asset is one file attached to a release. @@ -61,15 +66,28 @@ func (r *Release) AssetByName(name string) (Asset, bool) { // can point it at an httptest server — no test ever reaches github.com. type Client struct { HTTP *http.Client + Assets *http.Client // used by Download; falls back to HTTP when nil APIURL string UserAgent string } // NewClient returns a client that identifies itself with the running -// CommitBrief version and gives up after 30 seconds. +// CommitBrief version. HTTP (the API client) gives up after 10 seconds — +// a release-metadata response is a few KB and fast. +// +// Assets (used by Download) deliberately has no whole-request Timeout. +// http.Client.Timeout covers the entire round trip, including reading +// the response body, and a release archive can be several megabytes — +// a fixed deadline there fails a slow or throttled connection outright, +// permanently, no matter how much of the file already arrived. Instead +// it is bounded by ResponseHeaderTimeout (a stalled server still gives +// up after 30s) and by the context passed to Download for cancellation. func NewClient(version string) *Client { return &Client{ - HTTP: &http.Client{Timeout: 30 * time.Second}, + HTTP: &http.Client{Timeout: 10 * time.Second}, + Assets: &http.Client{ + Transport: &http.Transport{ResponseHeaderTimeout: 30 * time.Second}, + }, APIURL: DefaultAPIURL, UserAgent: "commitbrief/" + version, } @@ -103,7 +121,7 @@ func (c *Client) Latest(ctx context.Context) (*Release, error) { // The raw body is deliberately not echoed on a parse failure: an // error page can be arbitrarily long and is never actionable. if err := json.NewDecoder(io.LimitReader(resp.Body, maxDownloadBytes)).Decode(&rel); err != nil { - return nil, fmt.Errorf("github api: %w", err) + return nil, fmt.Errorf("%w: %v", ErrBadResponse, err) } if rel.TagName == "" { return nil, ErrNoRelease @@ -112,7 +130,9 @@ func (c *Client) Latest(ctx context.Context) (*Release, error) { } // Download streams url into w. Redirects are followed (GitHub sends -// release downloads to objects.githubusercontent.com). +// release downloads to objects.githubusercontent.com). Uses c.Assets — +// the client with no whole-request timeout — falling back to c.HTTP so +// a hand-constructed Client (as in tests) still works. func (c *Client) Download(ctx context.Context, url string, w io.Writer) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -120,7 +140,11 @@ func (c *Client) Download(ctx context.Context, url string, w io.Writer) error { } req.Header.Set("User-Agent", c.UserAgent) - resp, err := c.HTTP.Do(req) + client := c.Assets + if client == nil { + client = c.HTTP + } + resp, err := client.Do(req) if err != nil { return err } diff --git a/internal/upgrade/release_test.go b/internal/upgrade/release_test.go index 529597c..df3cc49 100644 --- a/internal/upgrade/release_test.go +++ b/internal/upgrade/release_test.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" ) func newTestClient(t *testing.T, h http.Handler) (*Client, *httptest.Server) { @@ -76,13 +77,16 @@ func TestLatestNoRelease(t *testing.T) { } } +// TestLatestMalformedJSON pins that a decode failure is reported as +// ErrBadResponse — the server was reached and answered, so this must +// not collapse into the same message as a network failure. func TestLatestMalformedJSON(t *testing.T) { c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("not json")) })) _, err := c.Latest(context.Background()) - if err == nil { - t.Fatal("Latest() error = nil, want a parse error") + if !errors.Is(err, ErrBadResponse) { + t.Fatalf("error = %v, want ErrBadResponse", err) } if errors.Is(err, ErrRateLimited) || errors.Is(err, ErrNoRelease) { t.Fatalf("error = %v, want a plain parse error", err) @@ -120,3 +124,31 @@ func TestDownloadRejectsNon200(t *testing.T) { t.Fatal("Download() error = nil, want a status error") } } + +// TestDownloadSurvivesSlowBody pins that Download has no whole-request +// deadline: headers arrive immediately, then the body trickles in after +// a delay that would have tripped the old 30s http.Client.Timeout (which +// covers the entire round trip, body included) had it still been set on +// the client Download uses. The client's ResponseHeaderTimeout is set +// low deliberately — proving it is irrelevant here, since headers are +// already flushed before the delay — while asserting nothing else in +// Download imposes a competing deadline on the slow body. +func TestDownloadSurvivesSlowBody(t *testing.T) { + c, srv := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + time.Sleep(100 * time.Millisecond) + _, _ = w.Write([]byte("payload")) + })) + c.Assets = &http.Client{Transport: &http.Transport{ResponseHeaderTimeout: 20 * time.Millisecond}} + + var buf bytes.Buffer + if err := c.Download(context.Background(), srv.URL, &buf); err != nil { + t.Fatalf("Download() error = %v, want nil — a slow body must not trip a whole-request deadline", err) + } + if buf.String() != "payload" { + t.Fatalf("body = %q, want %q", buf.String(), "payload") + } +} diff --git a/internal/upgrade/replace_test.go b/internal/upgrade/replace_test.go index d3d41b7..0259de1 100644 --- a/internal/upgrade/replace_test.go +++ b/internal/upgrade/replace_test.go @@ -48,3 +48,35 @@ func TestCleanupStaleIsSafeWhenNothingToClean(t *testing.T) { t.Fatalf("target disappeared: %v", err) } } + +// TestCleanupStaleRemovesTempFiles covers the orphan an interrupted +// InstallManual (Ctrl-C, before its defers run) leaves behind: a +// ".commitbrief-dl-*" or ".commitbrief-bin-*" scratch file next to the +// target, never cleaned up until the next upgrade sweeps it. +func TestCleanupStaleRemovesTempFiles(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "commitbrief") + if err := os.WriteFile(target, []byte("BIN"), 0o755); err != nil { + t.Fatal(err) + } + stale := []string{ + filepath.Join(dir, ".commitbrief-dl-abc123"), + filepath.Join(dir, ".commitbrief-bin-abc123"), + } + for _, f := range stale { + if err := os.WriteFile(f, []byte("orphan"), 0o600); err != nil { + t.Fatal(err) + } + } + + CleanupStale(target) + + for _, f := range stale { + if _, err := os.Stat(f); !os.IsNotExist(err) { + t.Fatalf("stale temp file still present: %s", f) + } + } + if _, err := os.Stat(target); err != nil { + t.Fatalf("target disappeared: %v", err) + } +} diff --git a/internal/upgrade/replace_unix.go b/internal/upgrade/replace_unix.go index 5b83572..8a1f826 100644 --- a/internal/upgrade/replace_unix.go +++ b/internal/upgrade/replace_unix.go @@ -15,5 +15,7 @@ func replaceBinary(tmp, target string) error { return os.Rename(tmp, target) } -// CleanupStale is a no-op on Unix — the rename leaves nothing behind. -func CleanupStale(target string) {} +// cleanupOld is a no-op on Unix — the rename leaves nothing +// platform-specific behind. Temp-file sweeping is shared across +// platforms; see CleanupStale in cleanup.go. +func cleanupOld(target string) {} diff --git a/internal/upgrade/replace_windows.go b/internal/upgrade/replace_windows.go index d952531..38022a9 100644 --- a/internal/upgrade/replace_windows.go +++ b/internal/upgrade/replace_windows.go @@ -47,9 +47,10 @@ func replaceBinary(tmp, target string) error { return nil } -// CleanupStale removes the moved-aside binary a previous upgrade could +// cleanupOld removes the moved-aside binary a previous upgrade could // not delete because it was still executing. Best effort by design: a -// failure here is never worth interrupting an upgrade over. -func CleanupStale(target string) { +// failure here is never worth interrupting an upgrade over. Temp-file +// sweeping is shared across platforms; see CleanupStale in cleanup.go. +func cleanupOld(target string) { _ = os.Remove(target + ".old") } From 08bb971bd6d5ae06111818640c9e573fdab1cb88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 26 Jul 2026 05:33:14 +0300 Subject: [PATCH 15/15] fix(upgrade): clone DefaultTransport for downloads, fix Windows path shadow check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round on the timeout/verify fixes from the previous commit: - Assets' transport was a bare &http.Transport{}, not DefaultTransport with ResponseHeaderTimeout changed. A zero-valued Transport drops Proxy (so HTTPS_PROXY/HTTP_PROXY was silently ignored for downloads, while the API client still honored it — a proxied user would see an update detected and then fail to download it) and DialContext/TLSHandshakeTimeout (so a blackholed connect or stalled handshake had no bound at all, since ResponseHeaderTimeout only starts after connect+TLS finish). Now built via http.DefaultTransport.(*http.Transport).Clone() with only ResponseHeaderTimeout overridden. - The download test asserted nothing about NewClient's actual client — it overrode Assets with its own and used a delay far short of any timeout it claimed to exercise, so it passed even against a broken Assets config. Replaced with a structural test that reads NewClient's fields directly; kept a corrected functional test alongside it. - shadowingPath compared paths with ==, which can false-positive on Windows over letter case or separator style that EvalSymlinks doesn't normalize. Exported detect.go's existing normalizePath logic as SamePath and used it instead — normalizePath itself is untouched. --- internal/cli/upgrade.go | 8 +++- internal/upgrade/detect.go | 17 ++++++++ internal/upgrade/detect_test.go | 59 ++++++++++++++++++++++++++++ internal/upgrade/release.go | 22 +++++++++-- internal/upgrade/release_test.go | 67 +++++++++++++++++++++++++++----- 5 files changed, 158 insertions(+), 15 deletions(-) diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index 5843378..34037c1 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -273,6 +273,12 @@ func verifyReplacement(cmd *cobra.Command, cat catalog, msg io.Writer, exe strin // to exe itself — neither is worth reporting, and a LookPath failure is // not an error in its own right, just the common case of a manual // install that was never put on PATH. +// +// Compares with upgrade.SamePath rather than == : on Windows, exe (from +// os.Executable + EvalSymlinks) and the PATH lookup can differ in +// letter case or `\`-vs-`/` without naming a different file — EvalSymlinks +// normalizes neither — and a plain == would warn about a shadow that +// does not exist. func shadowingPath(exe string) (shadow string, ok bool) { found, err := exec.LookPath("commitbrief") if err != nil { @@ -284,7 +290,7 @@ func shadowingPath(exe string) (shadow string, ok bool) { // compare what LookPath found, unresolved. resolved = found } - if resolved == exe { + if upgrade.SamePath(resolved, exe, runtime.GOOS) { return "", false } return resolved, true diff --git a/internal/upgrade/detect.go b/internal/upgrade/detect.go index 8b91fba..b1c02a0 100644 --- a/internal/upgrade/detect.go +++ b/internal/upgrade/detect.go @@ -116,6 +116,23 @@ func goBinDirs(env Env) []string { return dirs } +// SamePath reports whether a and b name the same filesystem location, +// under the platform rules normalizePath already applies for the +// marker comparisons above: case-insensitive and separator-normalized +// on Windows (whose filesystem is not case-sensitive), exact bytes +// everywhere else. goos is a parameter rather than read from runtime +// for the same reason Env.GOOS is: it lets a non-Windows host exercise +// the Windows comparison rules in a test. +// +// Exported for internal/cli's shadowingPath, which compares a +// PATH-resolved binary against the one just upgraded. Without this, it +// would warn about a shadowing commitbrief on Windows purely from a +// letter-case or `\`-vs-`/` difference that filepath.EvalSymlinks does +// not normalize away — a false positive, not a real shadow. +func SamePath(a, b, goos string) bool { + return normalizePath(a, goos) == normalizePath(b, goos) +} + // normalizePath lowercases on Windows (its paths are case-insensitive) // and converts separators to forward slashes so the marker checks above // can be written once instead of per-OS. diff --git a/internal/upgrade/detect_test.go b/internal/upgrade/detect_test.go index 5302bca..9c1b08e 100644 --- a/internal/upgrade/detect_test.go +++ b/internal/upgrade/detect_test.go @@ -83,3 +83,62 @@ func TestDetectWindowsIsCaseInsensitive(t *testing.T) { t.Fatalf("Detect() = %q, want %q", got, MethodScoop) } } + +// TestSamePath pins the comparison internal/cli's shadowingPath relies +// on: exact-byte on any non-Windows GOOS (unchanged from a plain ==), +// but case- and separator-insensitive on Windows, since neither +// os.Executable nor exec.LookPath is guaranteed to return byte-identical +// casing/separators for the same file, and EvalSymlinks normalizes +// neither. goos is passed explicitly so this runs the Windows rules on +// any host, the same way TestDetect exercises them. +func TestSamePath(t *testing.T) { + cases := []struct { + name string + a, b string + goos string + want bool + }{ + { + name: "windows case difference is the same path", + a: `C:\Users\ada\bin\commitbrief.exe`, + b: `C:\USERS\ada\BIN\CommitBrief.EXE`, + goos: "windows", + want: true, + }, + { + name: "windows separator difference is the same path", + a: `C:\Users\ada\bin\commitbrief.exe`, + b: `C:/Users/ada/bin/commitbrief.exe`, + goos: "windows", + want: true, + }, + { + name: "windows genuinely different paths do not match", + a: `C:\Users\ada\bin\commitbrief.exe`, + b: `C:\Program Files\CommitBrief\commitbrief.exe`, + goos: "windows", + want: false, + }, + { + name: "unix comparison stays case-sensitive", + a: "/usr/local/bin/commitbrief", + b: "/usr/local/bin/CommitBrief", + goos: "linux", + want: false, + }, + { + name: "unix identical paths match", + a: "/usr/local/bin/commitbrief", + b: "/usr/local/bin/commitbrief", + goos: "darwin", + want: true, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := SamePath(c.a, c.b, c.goos); got != c.want { + t.Fatalf("SamePath(%q, %q, %q) = %v, want %v", c.a, c.b, c.goos, got, c.want) + } + }) + } +} diff --git a/internal/upgrade/release.go b/internal/upgrade/release.go index 5a092d4..34ba0e5 100644 --- a/internal/upgrade/release.go +++ b/internal/upgrade/release.go @@ -82,12 +82,26 @@ type Client struct { // permanently, no matter how much of the file already arrived. Instead // it is bounded by ResponseHeaderTimeout (a stalled server still gives // up after 30s) and by the context passed to Download for cancellation. +// +// Assets' Transport is a *clone of http.DefaultTransport*, not a bare +// &http.Transport{} — a zero-valued Transport is a materially different, +// worse thing than "DefaultTransport with one field changed". A bare +// Transport has Proxy == nil, so it silently ignores HTTPS_PROXY/ +// HTTP_PROXY on a proxied network — while Latest's client (nil +// Transport ⇒ http.DefaultTransport) still honors it, so a user behind +// a corporate proxy would see `upgrade` correctly detect an update and +// then fail to download it. A bare Transport also has no DialContext +// and TLSHandshakeTimeout == 0; since ResponseHeaderTimeout only starts +// counting after connect + TLS finish, a blackholed endpoint would fall +// back to the OS TCP timeout (commonly 75s+) with an unbounded TLS +// handshake on top of that — nothing else in this codebase bounds it, +// since the context passed in has no deadline of its own. func NewClient(version string) *Client { + assetsTransport := http.DefaultTransport.(*http.Transport).Clone() + assetsTransport.ResponseHeaderTimeout = 30 * time.Second return &Client{ - HTTP: &http.Client{Timeout: 10 * time.Second}, - Assets: &http.Client{ - Transport: &http.Transport{ResponseHeaderTimeout: 30 * time.Second}, - }, + HTTP: &http.Client{Timeout: 10 * time.Second}, + Assets: &http.Client{Transport: assetsTransport}, APIURL: DefaultAPIURL, UserAgent: "commitbrief/" + version, } diff --git a/internal/upgrade/release_test.go b/internal/upgrade/release_test.go index df3cc49..512030c 100644 --- a/internal/upgrade/release_test.go +++ b/internal/upgrade/release_test.go @@ -125,14 +125,62 @@ func TestDownloadRejectsNon200(t *testing.T) { } } -// TestDownloadSurvivesSlowBody pins that Download has no whole-request -// deadline: headers arrive immediately, then the body trickles in after -// a delay that would have tripped the old 30s http.Client.Timeout (which -// covers the entire round trip, body included) had it still been set on -// the client Download uses. The client's ResponseHeaderTimeout is set -// low deliberately — proving it is irrelevant here, since headers are -// already flushed before the delay — while asserting nothing else in -// Download imposes a competing deadline on the slow body. +// TestNewClientTransportConfiguration pins the exact fields NewClient +// produces for Assets (the client Download uses) versus HTTP (the API +// client) — a structural check, not a timing-based one, because a +// timing-based test cannot reliably distinguish "no whole-request +// deadline" from "a deadline long enough not to fire in this test run" +// without either being slow or being flaky. A previous version of this +// test used a hand-built client with a short delay and passed +// regardless of what NewClient actually configured (proven by setting +// Assets.Timeout to 1ms and re-running: still green) — this test reads +// the fields straight off NewClient's return value instead, so it fails +// immediately if either regresses: +// - HTTP keeps a whole-request Timeout (release metadata is small). +// - Assets has Timeout == 0 (no whole-request deadline on a +// multi-megabyte download). +// - Assets' Transport is asserted as *http.Transport with a non-nil +// Proxy (so HTTPS_PROXY/HTTP_PROXY isn't silently dropped for +// downloads only) and a non-zero TLSHandshakeTimeout (so a stalled +// handshake — which ResponseHeaderTimeout does not bound, since it +// only starts counting after connect+TLS finish — cannot hang +// forever), confirming Assets was built from a clone of +// http.DefaultTransport rather than a bare &http.Transport{}. +// - Assets' Transport.ResponseHeaderTimeout is exactly 30s. +func TestNewClientTransportConfiguration(t *testing.T) { + c := NewClient("v1.14.0") + + if c.HTTP.Timeout != 10*time.Second { + t.Fatalf("HTTP.Timeout = %v, want 10s", c.HTTP.Timeout) + } + + if c.Assets.Timeout != 0 { + t.Fatalf("Assets.Timeout = %v, want 0 (no whole-request deadline)", c.Assets.Timeout) + } + tr, ok := c.Assets.Transport.(*http.Transport) + if !ok { + t.Fatalf("Assets.Transport = %T, want *http.Transport", c.Assets.Transport) + } + if tr.Proxy == nil { + t.Fatal("Assets.Transport.Proxy is nil — HTTPS_PROXY/HTTP_PROXY would be ignored for asset downloads") + } + if tr.TLSHandshakeTimeout == 0 { + t.Fatal("Assets.Transport.TLSHandshakeTimeout is 0 — a stalled TLS handshake would never time out") + } + if tr.ResponseHeaderTimeout != 30*time.Second { + t.Fatalf("Assets.Transport.ResponseHeaderTimeout = %v, want 30s", tr.ResponseHeaderTimeout) + } +} + +// TestDownloadSurvivesSlowBody exercises Download against the actual +// client NewClient produces (not a test-only override): headers arrive +// immediately, then the body trickles in after a short delay. This +// alone cannot prove there is no whole-request deadline — that boundary +// is pinned structurally, at the field level, by +// TestNewClientTransportConfiguration above — but it does catch a +// regression that reintroduces a deadline through some other path (a +// context timeout, a per-request deadline) without necessarily changing +// the Assets.Timeout field that test inspects. func TestDownloadSurvivesSlowBody(t *testing.T) { c, srv := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -142,11 +190,10 @@ func TestDownloadSurvivesSlowBody(t *testing.T) { time.Sleep(100 * time.Millisecond) _, _ = w.Write([]byte("payload")) })) - c.Assets = &http.Client{Transport: &http.Transport{ResponseHeaderTimeout: 20 * time.Millisecond}} var buf bytes.Buffer if err := c.Download(context.Background(), srv.URL, &buf); err != nil { - t.Fatalf("Download() error = %v, want nil — a slow body must not trip a whole-request deadline", err) + t.Fatalf("Download() error = %v, want nil", err) } if buf.String() != "payload" { t.Fatalf("body = %q, want %q", buf.String(), "payload")