diff --git a/cmd/status/metrics.go b/cmd/status/metrics.go
index d24e3f1..0224b66 100644
--- a/cmd/status/metrics.go
+++ b/cmd/status/metrics.go
@@ -347,7 +347,7 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
}, mergeErr
}
-func runCmd(ctx context.Context, name string, args ...string) (string, error) {
+var runCmd = func(ctx context.Context, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
output, err := cmd.Output()
if err != nil {
@@ -356,7 +356,7 @@ func runCmd(ctx context.Context, name string, args ...string) (string, error) {
return string(output), nil
}
-func commandExists(name string) bool {
+var commandExists = func(name string) bool {
if name == "" {
return false
}
diff --git a/cmd/status/metrics_disk.go b/cmd/status/metrics_disk.go
index d4cf39f..39a0a9d 100644
--- a/cmd/status/metrics_disk.go
+++ b/cmd/status/metrics_disk.go
@@ -67,26 +67,30 @@ func collectDisks() ([]DiskStatus, error) {
if err != nil || usage.Total == 0 {
continue
}
+ total := usage.Total
+ if runtime.GOOS == "darwin" {
+ total = correctDiskTotalBytes(part.Mountpoint, total)
+ }
// Skip <1GB volumes.
- if usage.Total < 1<<30 {
+ if total < 1<<30 {
continue
}
// Use size-based dedupe key for shared pools.
- volKey := fmt.Sprintf("%s:%d", part.Fstype, usage.Total)
+ volKey := fmt.Sprintf("%s:%d", part.Fstype, total)
if seenVolume[volKey] {
continue
}
used := usage.Used
usedPercent := usage.UsedPercent
if runtime.GOOS == "darwin" && strings.ToLower(part.Fstype) == "apfs" {
- used, usedPercent = correctAPFSDiskUsage(part.Mountpoint, usage.Total, usage.Used)
+ used, usedPercent = correctAPFSDiskUsage(part.Mountpoint, total, usage.Used)
}
disks = append(disks, DiskStatus{
Mount: part.Mountpoint,
Device: part.Device,
Used: used,
- Total: usage.Total,
+ Total: total,
UsedPercent: usedPercent,
Fstype: part.Fstype,
})
@@ -228,6 +232,39 @@ func isExternalDisk(device string) (bool, error) {
return external, nil
}
+// correctDiskTotalBytes uses diskutil's plist output when macOS reports a
+// meaningfully different disk size than gopsutil. This fixes external APFS
+// volumes that can show doubled capacities through statfs/gopsutil.
+func correctDiskTotalBytes(mountpoint string, rawTotal uint64) uint64 {
+ if rawTotal == 0 || !commandExists("diskutil") {
+ return rawTotal
+ }
+
+ diskutilTotal, err := getDiskutilTotalBytes(mountpoint)
+ if err != nil || diskutilTotal == 0 {
+ return rawTotal
+ }
+
+ if uint64AbsDiff(rawTotal, diskutilTotal) > 1<<30 {
+ return diskutilTotal
+ }
+
+ return rawTotal
+}
+
+func getDiskutilTotalBytes(mountpoint string) (uint64, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ out, err := runCmd(ctx, "diskutil", "info", "-plist", mountpoint)
+ if err != nil {
+ return 0, err
+ }
+
+ // Prefer TotalSize, but keep older/plainer keys as fallbacks.
+ return extractPlistUint(out, "TotalSize", "DiskSize", "Size")
+}
+
// correctAPFSDiskUsage returns Finder-accurate used bytes and percent for an
// APFS volume, accounting for purgeable caches and APFS local snapshots that
// statfs incorrectly counts as "used". Uses a three-tier fallback:
@@ -274,27 +311,7 @@ func getAPFSContainerFreeBytes(mountpoint string) (uint64, error) {
return 0, err
}
- const key = "APFSContainerFree"
- _, rest, found := strings.Cut(out, key)
- if !found {
- return 0, fmt.Errorf("APFSContainerFree not found")
- }
-
- _, rest, found = strings.Cut(rest, "")
- if !found {
- return 0, fmt.Errorf("APFSContainerFree value not found")
- }
-
- value, _, found := strings.Cut(rest, "")
- if !found {
- return 0, fmt.Errorf("APFSContainerFree end tag not found")
- }
-
- val, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
- if err != nil {
- return 0, fmt.Errorf("failed to parse APFSContainerFree: %v", err)
- }
- return val, nil
+ return extractPlistUint(out, "APFSContainerFree")
}
// getFinderStartupDiskFreeBytes queries Finder via osascript for the startup
@@ -336,6 +353,41 @@ func getFinderStartupDiskFreeBytes() (free, total uint64, err error) {
return finderDiskFree, finderDiskTotal, nil
}
+func extractPlistUint(plist string, keys ...string) (uint64, error) {
+ for _, key := range keys {
+ marker := "" + key + ""
+ _, rest, found := strings.Cut(plist, marker)
+ if !found {
+ continue
+ }
+
+ _, rest, found = strings.Cut(rest, "")
+ if !found {
+ continue
+ }
+
+ value, _, found := strings.Cut(rest, "")
+ if !found {
+ continue
+ }
+
+ parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
+ if err != nil {
+ return 0, fmt.Errorf("failed to parse %s: %v", key, err)
+ }
+ return parsed, nil
+ }
+
+ return 0, fmt.Errorf("%s not found", strings.Join(keys, "/"))
+}
+
+func uint64AbsDiff(a, b uint64) uint64 {
+ if a > b {
+ return a - b
+ }
+ return b - a
+}
+
func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus {
counters, err := disk.IOCounters()
if err != nil || len(counters) == 0 {
diff --git a/cmd/status/metrics_disk_test.go b/cmd/status/metrics_disk_test.go
index 32ed721..24fed33 100644
--- a/cmd/status/metrics_disk_test.go
+++ b/cmd/status/metrics_disk_test.go
@@ -1,6 +1,8 @@
package main
import (
+ "context"
+ "errors"
"testing"
"github.com/shirou/gopsutil/v4/disk"
@@ -58,3 +60,91 @@ func TestShouldSkipDiskPartition(t *testing.T) {
})
}
}
+
+func TestExtractPlistUint(t *testing.T) {
+ t.Run("prefers first matching key", func(t *testing.T) {
+ raw := `
+TotalSize1099511627776
+DiskSize2199023255552
+`
+
+ got, err := extractPlistUint(raw, "TotalSize", "DiskSize")
+ if err != nil {
+ t.Fatalf("extractPlistUint() error = %v", err)
+ }
+ if got != 1099511627776 {
+ t.Fatalf("extractPlistUint() = %d, want %d", got, uint64(1099511627776))
+ }
+ })
+
+ t.Run("falls back to later keys", func(t *testing.T) {
+ raw := `DiskSize1099511627776`
+
+ got, err := extractPlistUint(raw, "TotalSize", "DiskSize", "Size")
+ if err != nil {
+ t.Fatalf("extractPlistUint() error = %v", err)
+ }
+ if got != 1099511627776 {
+ t.Fatalf("extractPlistUint() = %d, want %d", got, uint64(1099511627776))
+ }
+ })
+
+ t.Run("returns error for malformed integer", func(t *testing.T) {
+ raw := `TotalSizeoops`
+
+ if _, err := extractPlistUint(raw, "TotalSize"); err == nil {
+ t.Fatalf("extractPlistUint() expected parse error")
+ }
+ })
+}
+
+func TestCorrectDiskTotalBytes(t *testing.T) {
+ origRunCmd := runCmd
+ origCommandExists := commandExists
+ t.Cleanup(func() {
+ runCmd = origRunCmd
+ commandExists = origCommandExists
+ })
+
+ commandExists = func(name string) bool {
+ return name == "diskutil"
+ }
+
+ t.Run("uses diskutil total when meaningfully different", func(t *testing.T) {
+ runCmd = func(ctx context.Context, name string, args ...string) (string, error) {
+ if name != "diskutil" {
+ return "", errors.New("unexpected command")
+ }
+ return `TotalSize1099511627776`, nil
+ }
+
+ got := correctDiskTotalBytes("/Volumes/Backup", 2199023255552)
+ if got != 1099511627776 {
+ t.Fatalf("correctDiskTotalBytes() = %d, want %d", got, uint64(1099511627776))
+ }
+ })
+
+ t.Run("keeps raw total for small differences", func(t *testing.T) {
+ runCmd = func(ctx context.Context, name string, args ...string) (string, error) {
+ return `TotalSize1000500000000`, nil
+ }
+
+ const rawTotal = 1000000000000
+ got := correctDiskTotalBytes("/Volumes/FastSSD", rawTotal)
+ if got != rawTotal {
+ t.Fatalf("correctDiskTotalBytes() = %d, want %d", got, uint64(rawTotal))
+ }
+ })
+
+ t.Run("keeps raw total when diskutil fails", func(t *testing.T) {
+ runCmd = func(ctx context.Context, name string, args ...string) (string, error) {
+ return "", errors.New("diskutil failed")
+ }
+
+ const rawTotal = 1099511627776
+ got := correctDiskTotalBytes("/Volumes/FastSSD", rawTotal)
+ if got != rawTotal {
+ t.Fatalf("correctDiskTotalBytes() = %d, want %d", got, uint64(rawTotal))
+ }
+ })
+}