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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ jobs:
path: artifacts
merge-multiple: true

- name: Generate checksums
run: |
cd artifacts
sha256sum *.tar.gz > SHA256SUMS
cat SHA256SUMS

- name: Get tag message
id: tag_message
run: |
Expand Down Expand Up @@ -97,6 +103,8 @@ jobs:
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: artifacts/*.tar.gz
files: |
artifacts/*.tar.gz
artifacts/SHA256SUMS
body: ${{ steps.tag_message.outputs.has_body == 'true' && steps.tag_message.outputs.body || '' }}
generate_release_notes: ${{ steps.tag_message.outputs.has_body != 'true' }}
128 changes: 124 additions & 4 deletions cmd/roborev/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/wesm/roborev/internal/daemon"
"github.com/wesm/roborev/internal/git"
"github.com/wesm/roborev/internal/storage"
"github.com/wesm/roborev/internal/update"
"github.com/wesm/roborev/internal/version"
)

Expand Down Expand Up @@ -48,6 +49,7 @@ func main() {
rootCmd.AddCommand(uninstallHookCmd())
rootCmd.AddCommand(daemonCmd())
rootCmd.AddCommand(tuiCmd())
rootCmd.AddCommand(updateCmd())
rootCmd.AddCommand(versionCmd())

if err := rootCmd.Execute(); err != nil {
Expand Down Expand Up @@ -744,9 +746,9 @@ func uninstallHookCmd() *cobra.Command {
return fmt.Errorf("read hook: %w", err)
}

// Check if it contains roborev
// Check if it contains roborev (case-insensitive)
hookStr := string(content)
if !strings.Contains(hookStr, "roborev") {
if !strings.Contains(strings.ToLower(hookStr), "roborev") {
fmt.Println("Post-commit hook does not contain roborev")
return nil
}
Expand All @@ -755,8 +757,8 @@ func uninstallHookCmd() *cobra.Command {
lines := strings.Split(hookStr, "\n")
var newLines []string
for _, line := range lines {
// Skip roborev-related lines
if strings.Contains(line, "roborev") || strings.Contains(line, "RoboRev") {
// Skip roborev-related lines (case-insensitive)
if strings.Contains(strings.ToLower(line), "roborev") {
continue
}
newLines = append(newLines, line)
Expand Down Expand Up @@ -792,6 +794,124 @@ func uninstallHookCmd() *cobra.Command {
}
}

func updateCmd() *cobra.Command {
var checkOnly bool
var yes bool

cmd := &cobra.Command{
Use: "update",
Short: "Update roborev to the latest version",
Long: `Check for and install roborev updates.

Shows exactly what will be downloaded and where it will be installed.
Requires confirmation before making changes (use --yes to skip).`,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Checking for updates...")

info, err := update.CheckForUpdate(true) // Force check, ignore cache
if err != nil {
return fmt.Errorf("check for updates: %w", err)
}

if info == nil {
fmt.Printf("Already running latest version (%s)\n", version.Version)
return nil
}

fmt.Printf("\n Current version: %s\n", info.CurrentVersion)
fmt.Printf(" Latest version: %s\n", info.LatestVersion)
fmt.Println("\nUpdate available!")
fmt.Println("\nDownload:")
fmt.Printf(" URL: %s\n", info.DownloadURL)
fmt.Printf(" Size: %s\n", update.FormatSize(info.Size))
if info.Checksum != "" {
fmt.Printf(" SHA256: %s\n", info.Checksum)
}

// Show install location
currentExe, err := os.Executable()
if err != nil {
return fmt.Errorf("find executable: %w", err)
}
currentExe, _ = filepath.EvalSymlinks(currentExe)
binDir := filepath.Dir(currentExe)

fmt.Println("\nInstall location:")
fmt.Printf(" %s\n", binDir)

if checkOnly {
return nil
}

// Confirm
if !yes {
fmt.Print("\nProceed with update? [y/N] ")
var response string
fmt.Scanln(&response)
if strings.ToLower(response) != "y" && strings.ToLower(response) != "yes" {
fmt.Println("Update cancelled")
return nil
}
}

fmt.Println()

// Progress display
var lastPercent int
progressFn := func(downloaded, total int64) {
if total > 0 {
percent := int(downloaded * 100 / total)
if percent != lastPercent {
fmt.Printf("\rDownloading... %d%% (%s / %s)",
percent, update.FormatSize(downloaded), update.FormatSize(total))
lastPercent = percent
}
}
}

// Perform update
if err := update.PerformUpdate(info, progressFn); err != nil {
return fmt.Errorf("update failed: %w", err)
}

fmt.Printf("\nUpdated to %s\n", info.LatestVersion)

// Restart daemon if running
if daemonInfo, err := daemon.ReadRuntime(); err == nil && daemonInfo != nil {
fmt.Print("Restarting daemon... ")
// Stop old daemon with timeout
stopURL := fmt.Sprintf("http://%s/api/shutdown", daemonInfo.Addr)
client := &http.Client{Timeout: 5 * time.Second}
if resp, err := client.Post(stopURL, "application/json", nil); err != nil {
fmt.Printf("warning: failed to stop daemon: %v\n", err)
} else {
resp.Body.Close()
}
time.Sleep(500 * time.Millisecond)

// Start new daemon
daemonPath := filepath.Join(binDir, "roborevd")
if runtime.GOOS == "windows" {
daemonPath += ".exe"
}
startCmd := exec.Command(daemonPath)
if err := startCmd.Start(); err != nil {
fmt.Printf("warning: failed to start daemon: %v\n", err)
} else {
fmt.Println("OK")
}
}

return nil
},
}

cmd.Flags().BoolVar(&checkOnly, "check", false, "only check for updates, don't install")
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip confirmation prompt")

return cmd
}

func versionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Expand Down
144 changes: 144 additions & 0 deletions cmd/roborev/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/wesm/roborev/internal/daemon"
Expand Down Expand Up @@ -162,3 +163,146 @@ func TestEnqueueCmdPositionalArg(t *testing.T) {
}
})
}

func TestUninstallHookCmd(t *testing.T) {
// Helper to create a git repo with an optional hook
setupRepo := func(t *testing.T, hookContent string) (repoPath string, hookPath string) {
tmpDir := t.TempDir()

// Initialize git repo
cmd := exec.Command("git", "init")
cmd.Dir = tmpDir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git init failed: %v\n%s", err, out)
}

hookPath = filepath.Join(tmpDir, ".git", "hooks", "post-commit")

if hookContent != "" {
if err := os.MkdirAll(filepath.Dir(hookPath), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(hookPath, []byte(hookContent), 0755); err != nil {
t.Fatal(err)
}
}

return tmpDir, hookPath
}

t.Run("hook missing", func(t *testing.T) {
repoPath, hookPath := setupRepo(t, "")

// Change to repo dir for the command
origDir, _ := os.Getwd()
os.Chdir(repoPath)
defer os.Chdir(origDir)

cmd := uninstallHookCmd()
err := cmd.Execute()
if err != nil {
t.Fatalf("uninstall-hook failed: %v", err)
}

// Hook should still not exist
if _, err := os.Stat(hookPath); !os.IsNotExist(err) {
t.Error("Hook file should not exist")
}
})

t.Run("hook without roborev", func(t *testing.T) {
hookContent := "#!/bin/bash\necho 'other hook'\n"
repoPath, hookPath := setupRepo(t, hookContent)

origDir, _ := os.Getwd()
os.Chdir(repoPath)
defer os.Chdir(origDir)

cmd := uninstallHookCmd()
err := cmd.Execute()
if err != nil {
t.Fatalf("uninstall-hook failed: %v", err)
}

// Hook should be unchanged
content, err := os.ReadFile(hookPath)
if err != nil {
t.Fatalf("Failed to read hook: %v", err)
}
if string(content) != hookContent {
t.Errorf("Hook content changed: got %q, want %q", string(content), hookContent)
}
})

t.Run("hook with roborev only - removes file", func(t *testing.T) {
hookContent := "#!/bin/bash\n# RoboRev auto-commit hook\nroborev enqueue\n"
repoPath, hookPath := setupRepo(t, hookContent)

origDir, _ := os.Getwd()
os.Chdir(repoPath)
defer os.Chdir(origDir)

cmd := uninstallHookCmd()
err := cmd.Execute()
if err != nil {
t.Fatalf("uninstall-hook failed: %v", err)
}

// Hook should be removed entirely
if _, err := os.Stat(hookPath); !os.IsNotExist(err) {
t.Error("Hook file should have been removed")
}
})

t.Run("hook with roborev and other commands - preserves others", func(t *testing.T) {
hookContent := "#!/bin/bash\necho 'before'\nroborev enqueue\necho 'after'\n"
repoPath, hookPath := setupRepo(t, hookContent)

origDir, _ := os.Getwd()
os.Chdir(repoPath)
defer os.Chdir(origDir)

cmd := uninstallHookCmd()
err := cmd.Execute()
if err != nil {
t.Fatalf("uninstall-hook failed: %v", err)
}

// Hook should exist with roborev line removed
content, err := os.ReadFile(hookPath)
if err != nil {
t.Fatalf("Failed to read hook: %v", err)
}

contentStr := string(content)
if strings.Contains(strings.ToLower(contentStr), "roborev") {
t.Error("Hook should not contain roborev")
}
if !strings.Contains(contentStr, "echo 'before'") {
t.Error("Hook should still contain 'echo before'")
}
if !strings.Contains(contentStr, "echo 'after'") {
t.Error("Hook should still contain 'echo after'")
}
})

t.Run("hook with capitalized RoboRev", func(t *testing.T) {
hookContent := "#!/bin/bash\n# RoboRev hook\nRoboRev enqueue\n"
repoPath, hookPath := setupRepo(t, hookContent)

origDir, _ := os.Getwd()
os.Chdir(repoPath)
defer os.Chdir(origDir)

cmd := uninstallHookCmd()
err := cmd.Execute()
if err != nil {
t.Fatalf("uninstall-hook failed: %v", err)
}

// Hook should be removed (only had RoboRev content)
if _, err := os.Stat(hookPath); !os.IsNotExist(err) {
t.Error("Hook file should have been removed")
}
})
}
Loading
Loading