Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht

## [Unreleased]

### Security
- Disabled automatic `xgit update` binary replacement until release authenticity verification is implemented.

## [0.1.5] - 2026-03-11

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ In short: **xgit is not a new VCS**. It is a practical AI layer over the Git you
- `xgit test-fix`: propose and validate fixes for failing tests
- `xgit doc`: generate/update project docs
- `xgit auto`: state-aware automation (conflict/test/diff driven)
- `xgit update`: check the latest release and self-update on macOS/Linux
- `xgit update`: check the latest release; automatic self-update is disabled until releases are authenticity-verified

- **Workflow scaling**
- `xgit alias`: compose multiple xgit commands into one reusable command
Expand Down
6 changes: 3 additions & 3 deletions cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ func newUpdateCommand() *cobra.Command {

cmd := &cobra.Command{
Use: "update",
Short: "Check for updates and optionally self-update xgit",
Short: "Check for xgit release updates",
RunE: func(cmd *cobra.Command, args []string) error {
return workflow.RunUpdate(context.Background(), workflow.UpdateOptions{
Repo: repo,
Expand All @@ -28,8 +28,8 @@ func newUpdateCommand() *cobra.Command {
}

cmd.Flags().StringVar(&repo, "repo", "hjun1052/xgit", "GitHub repository in owner/name format")
cmd.Flags().StringVar(&releaseVersion, "version", "latest", "Release tag to install (default: latest)")
cmd.Flags().StringVar(&releaseVersion, "version", "latest", "Release tag to check (default: latest)")
cmd.Flags().BoolVar(&checkOnly, "check", false, "Only check whether an update is available")
cmd.Flags().BoolVar(&autoApply, "yes", false, "Install the update without prompting")
cmd.Flags().BoolVar(&autoApply, "yes", false, "Deprecated; automatic self-update is disabled")
return cmd
}
154 changes: 9 additions & 145 deletions internal/workflow/update.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
package workflow

import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
Expand All @@ -36,12 +30,10 @@ type githubRelease struct {
}

var (
updateHTTPClient = http.DefaultClient
updateAPIBaseURL = "https://api.github.com"
updateRuntimeGOOS = runtime.GOOS
updateRuntimeGOARCH = runtime.GOARCH
currentExecutablePath = os.Executable
applyDownloadedBinary = replaceCurrentExecutable
updateHTTPClient = http.DefaultClient
updateAPIBaseURL = "https://api.github.com"
updateRuntimeGOOS = runtime.GOOS
updateRuntimeGOARCH = runtime.GOARCH
)

func RunUpdate(ctx context.Context, opts UpdateOptions) error {
Expand All @@ -62,8 +54,7 @@ func RunUpdate(ctx context.Context, opts UpdateOptions) error {
currentVersion := normalizeVersion(versionpkg.Version)
targetVersion := normalizeVersion(release.TagName)
assetName := expectedReleaseAssetName(targetVersion)
assetURL, err := findReleaseAssetURL(release, assetName)
if err != nil {
if err := ensureReleaseAssetExists(release, assetName); err != nil {
return err
}
Comment on lines 54 to 59

Expand All @@ -80,36 +71,7 @@ func RunUpdate(ctx context.Context, opts UpdateOptions) error {
return nil
}

if !opts.AutoApply {
ok, err := requestApplyApproval("Install update now? [y/N]: ")
if err != nil {
return err
}
if !ok {
fmt.Println("Update skipped.")
return nil
}
}

binaryPath := opts.CurrentPath
if strings.TrimSpace(binaryPath) == "" {
binaryPath, err = currentExecutablePath()
if err != nil {
return fmt.Errorf("resolve current executable: %w", err)
}
}

binaryBytes, err := downloadReleaseBinary(ctx, assetURL)
if err != nil {
return err
}
if err := applyDownloadedBinary(binaryPath, binaryBytes); err != nil {
return err
}

fmt.Printf("Updated xgit from %s to %s\n", versionpkg.Version, release.TagName)
fmt.Printf("Installed binary: %s\n", binaryPath)
return nil
return fmt.Errorf("automatic self-update is disabled until release authenticity verification is implemented; download and verify %s manually from the project releases", assetName)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep plain update checks from failing

With the CLI default in cmd/update.go, CheckOnly is false unless the user explicitly passes --check, so a normal xgit update now prints the available release and then exits non-zero whenever an update exists. That contradicts the new check-only behavior advertised by the help/docs and makes the preserved release-check path fail for the default command; if only automatic installation is disabled, this should error only for the deprecated auto-apply path or otherwise return successfully after reporting the asset.

Useful? React with 👍 / 👎.

}

func fetchRelease(ctx context.Context, repo, releaseVersion string) (githubRelease, error) {
Expand Down Expand Up @@ -155,111 +117,13 @@ func expectedReleaseAssetName(version string) string {
return fmt.Sprintf("xgit_%s_%s_%s.%s", strings.TrimPrefix(version, "v"), updateRuntimeGOOS, updateRuntimeGOARCH, ext)
}

func findReleaseAssetURL(release githubRelease, expectedName string) (string, error) {
func ensureReleaseAssetExists(release githubRelease, expectedName string) error {
for _, asset := range release.Assets {
if asset.Name == expectedName {
return asset.BrowserDownloadURL, nil
}
}
return "", fmt.Errorf("release %s does not contain asset %s", release.TagName, expectedName)
}

func downloadReleaseBinary(ctx context.Context, assetURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "xgit/"+normalizeVersion(versionpkg.Version))

resp, err := updateHTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("update download failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
}

archiveBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if updateRuntimeGOOS == "windows" {
return extractBinaryFromZip(archiveBytes)
}
return extractBinaryFromTarGz(archiveBytes)
}

func extractBinaryFromTarGz(archiveBytes []byte) ([]byte, error) {
gzr, err := gzip.NewReader(bytes.NewReader(archiveBytes))
if err != nil {
return nil, err
}
defer gzr.Close()

tr := tar.NewReader(gzr)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if filepath.Base(hdr.Name) != "xgit" {
continue
}
return io.ReadAll(tr)
}
return nil, fmt.Errorf("archive did not contain xgit binary")
}

func extractBinaryFromZip(archiveBytes []byte) ([]byte, error) {
zr, err := zip.NewReader(bytes.NewReader(archiveBytes), int64(len(archiveBytes)))
if err != nil {
return nil, err
}
for _, file := range zr.File {
base := strings.ToLower(filepath.Base(file.Name))
if base != "xgit.exe" && base != "xgit" {
continue
}
rc, err := file.Open()
if err != nil {
return nil, err
return nil
}
defer rc.Close()
return io.ReadAll(rc)
}
return nil, fmt.Errorf("archive did not contain xgit.exe binary")
}

func replaceCurrentExecutable(targetPath string, binaryBytes []byte) error {
if updateRuntimeGOOS == "windows" {
return fmt.Errorf("automatic self-update is not supported on windows yet; use scripts/install.ps1")
}

dir := filepath.Dir(targetPath)
tmp, err := os.CreateTemp(dir, "xgit-update-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)

if _, err := tmp.Write(binaryBytes); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmpPath, 0o755); err != nil {
return err
}
return os.Rename(tmpPath, targetPath)
return fmt.Errorf("release %s does not contain asset %s", release.TagName, expectedName)
}

func isUpdateAvailable(current, target string) bool {
Expand Down
Loading
Loading