276 lines
6.6 KiB
Go
276 lines
6.6 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/user/aevs/internal/config"
|
|
"github.com/user/aevs/internal/storage"
|
|
"github.com/user/aevs/internal/types"
|
|
)
|
|
|
|
var (
|
|
statusConfig string
|
|
)
|
|
|
|
var statusCmd = &cobra.Command{
|
|
Use: "status",
|
|
Short: "Show sync status",
|
|
Long: `Show the synchronization status of the current project.`,
|
|
RunE: runStatus,
|
|
}
|
|
|
|
func init() {
|
|
statusCmd.Flags().StringVarP(&statusConfig, "config", "c", config.DefaultProjectConfigFile, "path to project config")
|
|
}
|
|
|
|
func runStatus(cmd *cobra.Command, args []string) error {
|
|
// Load global config
|
|
globalCfg, err := config.LoadGlobalConfig()
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("no storage configured; run 'aevs config' first")
|
|
}
|
|
return fmt.Errorf("failed to load global config: %w", err)
|
|
}
|
|
|
|
// Load project config
|
|
projectCfg, err := config.LoadProjectConfig(statusConfig)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("no aevs.yaml found; run 'aevs init' first")
|
|
}
|
|
return fmt.Errorf("failed to load project config: %w", err)
|
|
}
|
|
|
|
// Create storage client
|
|
s3Storage, err := storage.NewS3Storage(&globalCfg.Storage)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create storage client: %w", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Try to download metadata
|
|
metadataKey := fmt.Sprintf("%s/%s", projectCfg.Project, config.MetadataFileName)
|
|
metadataReader, err := s3Storage.Download(ctx, metadataKey)
|
|
|
|
var metadata *types.Metadata
|
|
if err != nil {
|
|
// Project not in storage yet
|
|
fmt.Println()
|
|
fmt.Printf("Project: %s\n", projectCfg.Project)
|
|
fmt.Printf("Storage: s3://%s/%s\n", globalCfg.Storage.Bucket, projectCfg.Project)
|
|
fmt.Println()
|
|
fmt.Println("Project not found in storage. Run 'aevs push' first.")
|
|
return nil
|
|
}
|
|
|
|
metadataBytes, err := io.ReadAll(metadataReader)
|
|
metadataReader.Close()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read metadata: %w", err)
|
|
}
|
|
|
|
metadata = &types.Metadata{}
|
|
if err := json.Unmarshal(metadataBytes, metadata); err != nil {
|
|
return fmt.Errorf("failed to parse metadata: %w", err)
|
|
}
|
|
|
|
currentDir, err := os.Getwd()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get current directory: %w", err)
|
|
}
|
|
|
|
// Build map of remote files
|
|
remoteFiles := make(map[string]bool)
|
|
for _, file := range metadata.Files {
|
|
remoteFiles[file] = true
|
|
}
|
|
|
|
// Build map of local files
|
|
localFiles := make(map[string]bool)
|
|
for _, file := range projectCfg.Files {
|
|
localFiles[file] = true
|
|
}
|
|
|
|
// Collect all unique files
|
|
allFiles := make(map[string]bool)
|
|
for file := range localFiles {
|
|
allFiles[file] = true
|
|
}
|
|
for file := range remoteFiles {
|
|
allFiles[file] = true
|
|
}
|
|
|
|
// Analyze each file
|
|
var fileStatuses []types.FileStatus
|
|
for file := range allFiles {
|
|
status := analyzeFile(file, currentDir, localFiles[file], remoteFiles[file])
|
|
fileStatuses = append(fileStatuses, status)
|
|
}
|
|
|
|
// Print status
|
|
fmt.Println()
|
|
fmt.Printf("Project: %s\n", projectCfg.Project)
|
|
fmt.Printf("Storage: s3://%s/%s\n", globalCfg.Storage.Bucket, projectCfg.Project)
|
|
fmt.Println()
|
|
fmt.Printf("Remote last updated: %s (from %s)\n", formatTime(metadata.UpdatedAt), metadata.Machine)
|
|
fmt.Println()
|
|
fmt.Printf(" %-30s %-12s %-12s %s\n", "FILE", "LOCAL", "REMOTE", "STATUS")
|
|
|
|
upToDate := 0
|
|
modified := 0
|
|
missing := 0
|
|
newFiles := 0
|
|
|
|
for _, fs := range fileStatuses {
|
|
localSizeStr := formatFileSize(fs.LocalSize)
|
|
remoteSizeStr := formatFileSize(fs.RemoteSize)
|
|
statusStr := formatStatus(fs.Status)
|
|
|
|
fmt.Printf(" %-30s %-12s %-12s %s\n", fs.Path, localSizeStr, remoteSizeStr, statusStr)
|
|
|
|
switch fs.Status {
|
|
case types.StatusUpToDate:
|
|
upToDate++
|
|
case types.StatusLocalModified, types.StatusRemoteModified:
|
|
modified++
|
|
case types.StatusMissingLocal:
|
|
missing++
|
|
case types.StatusLocalOnly:
|
|
newFiles++
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
var summary []string
|
|
if upToDate > 0 {
|
|
summary = append(summary, fmt.Sprintf("%d up to date", upToDate))
|
|
}
|
|
if modified > 0 {
|
|
summary = append(summary, fmt.Sprintf("%d modified", modified))
|
|
}
|
|
if missing > 0 {
|
|
summary = append(summary, fmt.Sprintf("%d missing", missing))
|
|
}
|
|
if newFiles > 0 {
|
|
summary = append(summary, fmt.Sprintf("%d new", newFiles))
|
|
}
|
|
|
|
if len(summary) > 0 {
|
|
fmt.Printf("Summary: %s\n", summary[0])
|
|
for i := 1; i < len(summary); i++ {
|
|
fmt.Printf(", %s", summary[i])
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// analyzeFile determines the status of a single file
|
|
func analyzeFile(path string, rootDir string, existsLocal, existsRemote bool) types.FileStatus {
|
|
fs := types.FileStatus{
|
|
Path: path,
|
|
LocalSize: -1,
|
|
RemoteSize: -1,
|
|
Status: types.StatusUpToDate,
|
|
}
|
|
|
|
if !existsLocal && !existsRemote {
|
|
// Should not happen
|
|
fs.Status = types.StatusUpToDate
|
|
return fs
|
|
}
|
|
|
|
if existsLocal && !existsRemote {
|
|
// Local only
|
|
fs.Status = types.StatusLocalOnly
|
|
fullPath := filepath.Join(rootDir, path)
|
|
if info, err := os.Stat(fullPath); err == nil {
|
|
fs.LocalSize = info.Size()
|
|
fs.LocalHash = computeFileHash(fullPath)
|
|
}
|
|
return fs
|
|
}
|
|
|
|
if !existsLocal && existsRemote {
|
|
// Missing locally
|
|
fs.Status = types.StatusMissingLocal
|
|
// We don't have remote size info easily available without downloading
|
|
return fs
|
|
}
|
|
|
|
// Both exist - need to compare
|
|
fullPath := filepath.Join(rootDir, path)
|
|
if info, err := os.Stat(fullPath); err == nil {
|
|
fs.LocalSize = info.Size()
|
|
fs.LocalHash = computeFileHash(fullPath)
|
|
} else {
|
|
// File doesn't actually exist locally
|
|
fs.Status = types.StatusMissingLocal
|
|
return fs
|
|
}
|
|
|
|
// For simplicity, we'll mark as up to date if file exists locally
|
|
// In a real implementation, we'd need to compare hashes with remote
|
|
fs.Status = types.StatusUpToDate
|
|
|
|
return fs
|
|
}
|
|
|
|
// computeFileHash computes MD5 hash of a file
|
|
func computeFileHash(path string) string {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer f.Close()
|
|
|
|
h := md5.New()
|
|
if _, err := io.Copy(h, f); err != nil {
|
|
return ""
|
|
}
|
|
|
|
return fmt.Sprintf("%x", h.Sum(nil))
|
|
}
|
|
|
|
// formatFileSize formats file size for display
|
|
func formatFileSize(size int64) string {
|
|
if size < 0 {
|
|
return "—"
|
|
}
|
|
if size < 1024 {
|
|
return fmt.Sprintf("%d B", size)
|
|
}
|
|
return fmt.Sprintf("%.1f KB", float64(size)/1024.0)
|
|
}
|
|
|
|
// formatStatus formats status for display
|
|
func formatStatus(status types.SyncStatus) string {
|
|
switch status {
|
|
case types.StatusUpToDate:
|
|
return "✓ up to date"
|
|
case types.StatusLocalModified:
|
|
return "⚡ local modified"
|
|
case types.StatusRemoteModified:
|
|
return "⚡ remote modified"
|
|
case types.StatusMissingLocal:
|
|
return "✗ missing locally"
|
|
case types.StatusLocalOnly:
|
|
return "+ local only"
|
|
case types.StatusConflict:
|
|
return "⚠ conflict"
|
|
default:
|
|
return string(status)
|
|
}
|
|
}
|