Repository: kopia/kopia (go)

Issue:
[Feature request] Human readable sizes for `kopia ls`
Feature request: human readable sizes for `kopia ls`. I typically backup with kopia then save `kopia -lr` output to a text file which I then `grep` for filenames and their sizes, which I often refer to after trimming the same file (to e.g. compare size differences). But for all other typical CLI applications, they provide a human-readable option to describe sizes in practical units. Currently Kopia doesn't have this option, printing only in bytes.

Pull request:
feat(cli): human-readable sizes for kopia ls
Adds a `-h`/`--human-readable` option to `kopia ls` to print sizes in human-readable format.

- Closes #4501

Patch:
diff --git a/.github/workflows/make.yml b/.github/workflows/make.yml
index b10e1666..7d0b4d98 100644
--- a/.github/workflows/make.yml
+++ b/.github/workflows/make.yml
@@ -133,7 +133,7 @@ jobs:
     - name: Set up QEMU
       uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
     - name: Set up Docker Buildx
-      uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
+      uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
     - name: Install Linux-specific packages
       run: "sudo apt-get install -y createrepo-c"
     - name: Download Artifacts
@@ -186,7 +186,7 @@ jobs:
         DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
         DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
     - name: Bump Homebrew formula
-      uses: dawidd6/action-homebrew-bump-formula@8d494330bce4434918392df134ad3db1167904db # v4
+      uses: dawidd6/action-homebrew-bump-formula@3428a0601bba3173ec0bdcc945be23fa27aa4c31 # v5
       # only bump formula for tags which don't contain '-'
       # this excludes vx.y.z-rc1
       if: github.ref_type == 'tag' && !contains(github.ref_name, '-')
diff --git a/.github/workflows/ossf-scorecard.yml b/.github/workflows/ossf-scorecard.yml
index 44bc66db..520a2956 100644
--- a/.github/workflows/ossf-scorecard.yml
+++ b/.github/workflows/ossf-scorecard.yml
@@ -39,7 +39,7 @@ jobs:
       -
         # Upload the results to GitHub's code scanning dashboard.
         name: "Upload to results to dashboard"
-        uses: github/codeql-action/upload-sarif@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
+        uses: github/codeql-action/upload-sarif@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
         with:
           sarif_file: results.sarif
       -
diff --git a/.gometalinter.json b/.gometalinter.json
deleted file mode 100644
index dea60f4a..00000000
--- a/.gometalinter.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
-    "Disable": ["maligned","gas","gosec"],
-    "Exclude": [
-        "playground/",
-        ".+\\.pb\\.go",
-        ".+_test\\.go"
-    ],
-    "Deadline": "120s"
-}
\ No newline at end of file
diff --git a/cli/command_ls.go b/cli/command_ls.go
index 651d3470..9db1baa5 100644
--- a/cli/command_ls.go
+++ b/cli/command_ls.go
@@ -15,6 +15,7 @@
 
 type commandList struct {
 	long         bool
+	humanSizes   bool
 	recursive    bool
 	showOID      bool
 	errorSummary bool
@@ -27,6 +28,7 @@ func (c *commandList) setup(svc appServices, parent commandParent) {
 	cmd := parent.Command("list", "List a directory stored in repository object.").Alias("ls")
 
 	cmd.Flag("long", "Long output").Short('l').BoolVar(&c.long)
+	cmd.Flag("human-readable", "Show human-readable sizes").Short('h').BoolVar(&c.humanSizes)
 	cmd.Flag("recursive", "Recursive output").Short('r').BoolVar(&c.recursive)
 	cmd.Flag("show-object-id", "Show object IDs").Short('o').BoolVar(&c.showOID)
 	cmd.Flag("error-summary", "Emit error summary").Default("true").BoolVar(&c.errorSummary)
@@ -111,9 +113,9 @@ func (c *commandList) printDirectoryEntry(ctx context.Context, e fs.Entry, prefi
 	switch {
 	case c.long:
 		info = fmt.Sprintf(
-			"%v %12d %v %-34v %v%v",
+			"%v %12s %v %-34v %v%v",
 			e.Mode(),
-			e.Size(),
+			maybeHumanReadableBytes(c.humanSizes, e.Size()),
 			formatTimestamp(e.ModTime().Local()),
 			oid,
 			c.nameToDisplay(prefix, e),
diff --git a/go.mod b/go.mod
index 2a253e5b..1e48ac53 100644
--- a/go.mod
+++ b/go.mod
@@ -35,7 +35,7 @@ require (
 	github.com/mattn/go-colorable v0.1.14
 	github.com/mattn/go-isatty v0.0.20
 	github.com/minio/minio-go/v7 v7.0.94
-	github.com/mocktools/go-smtp-mock/v2 v2.5.0
+	github.com/mocktools/go-smtp-mock/v2 v2.5.1
 	github.com/mxk/go-vss v1.2.0
 	github.com/natefinch/atomic v1.0.1
 	github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9
@@ -53,10 +53,10 @@ require (
 	github.com/tg123/go-htpasswd v1.2.4
 	github.com/zalando/go-keyring v0.2.6
 	github.com/zeebo/blake3 v0.2.4
-	go.opentelemetry.io/otel v1.36.0
-	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0
-	go.opentelemetry.io/otel/sdk v1.36.0
-	go.opentelemetry.io/otel/trace v1.36.0
+	go.opentelemetry.io/otel v1.37.0
+	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0
+	go.opentelemetry.io/otel/sdk v1.37.0
+	go.opentelemetry.io/otel/trace v1.37.0
 	go.uber.org/zap v1.27.0
 	golang.org/x/crypto v0.39.0
 	golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1
@@ -67,7 +67,7 @@ require (
 	golang.org/x/sys v0.33.0
 	golang.org/x/term v0.32.0
 	golang.org/x/text v0.26.0
-	google.golang.org/api v0.238.0
+	google.golang.org/api v0.239.0
 	google.golang.org/grpc v1.73.0
 	google.golang.org/protobuf v1.36.6
 	gopkg.in/kothar/go-backblaze.v0 v0.0.0-20210124194846-35409b867216
@@ -104,7 +104,7 @@ require (
 	github.com/go-ini/ini v1.67.0 // indirect
 	github.com/go-jose/go-jose/v4 v4.0.5 // indirect
 	github.com/go-json-experiment/json v0.0.0-20250211171154-1ae217ad3535 // indirect
-	github.com/go-logr/logr v1.4.2 // indirect
+	github.com/go-logr/logr v1.4.3 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/go-ole/go-ole v1.3.0 // indirect
 	github.com/gobwas/httphead v0.1.0 // indirect
@@ -119,7 +119,7 @@ require (
 	github.com/google/s2a-go v0.1.9 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
 	github.com/googleapis/gax-go/v2 v2.14.2 // indirect
-	github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
+	github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect
 	github.com/klauspost/cpuid/v2 v2.2.10 // indirect
 	github.com/kr/fs v0.1.0 // indirect
 	github.com/minio/crc64nvme v1.0.1 // indirect
@@ -140,14 +140,14 @@ require (
 	go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
-	go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 // indirect
-	go.opentelemetry.io/otel/metric v1.36.0 // indirect
+	go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect
+	go.opentelemetry.io/otel/metric v1.37.0 // indirect
 	go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect
-	go.opentelemetry.io/proto/otlp v1.6.0 // indirect
+	go.opentelemetry.io/proto/otlp v1.7.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	golang.org/x/time v0.12.0 // indirect
 	google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/go.sum b/go.sum
index 57544f3e..334293cb 100644
--- a/go.sum
+++ b/go.sum
@@ -114,8 +114,8 @@ github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JS
 github.com/go-json-experiment/json v0.0.0-20250211171154-1ae217ad3535 h1:yE7argOs92u+sSCRgqqe6eF+cDaVhSPlioy1UkA0p/w=
 github.com/go-json-experiment/json v0.0.0-20250211171154-1ae217ad3535/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s=
 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
-github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
-github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
@@ -166,8 +166,8 @@ github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3
 github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
 github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
 github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
 github.com/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs=
 github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
 github.com/hashicorp/cronexpr v1.1.2 h1:wG/ZYIKT+RT3QkOdgYc+xsKWVRgnxJ1OJtjjy84fJ9A=
@@ -212,8 +212,8 @@ github.com/minio/minio-go/v7 v7.0.94 h1:1ZoksIKPyaSt64AVOyaQvhDOgVC3MfZsWM6mZXRU
 github.com/minio/minio-go/v7 v7.0.94/go.mod h1:71t2CqDt3ThzESgZUlU1rBN54mksGGlkLcFgguDnnAc=
 github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
 github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
-github.com/mocktools/go-smtp-mock/v2 v2.5.0 h1:0wUW3YhTHUO6SEqWczCHpLynwIfXieGtxpWJa44YVCM=
-github.com/mocktools/go-smtp-mock/v2 v2.5.0/go.mod h1:h9AOf/IXLSU2m/1u4zsjtOM/WddPwdOUBz56dV9f81M=
+github.com/mocktools/go-smtp-mock/v2 v2.5.1 h1:QcMJMChSgG1olVj4o6xxQFdrWzRjYNrcq660HAjd0wA=
+github.com/mocktools/go-smtp-mock/v2 v2.5.1/go.mod h1:Rr8M2njlxx//l5INl2+uESnsL2lDsL24teEykCrGfmE=
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 github.com/mxk/go-vss v1.2.0 h1:JpdOPc/P6B3XyRoddn0iMiG/ADBi3AuEsv8RlTb+JeE=
@@ -274,7 +274,6 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/studio-b12/gowebdav v0.10.0 h1:Yewz8FFiadcGEu4hxS/AAJQlHelndqln1bns3hcJIYc=
@@ -304,24 +303,24 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6
 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
-go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
-go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0 h1:JgtbA0xkWHnTmYk7YusopJFX6uleBmAuZ8n05NEh8nQ=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0/go.mod h1:179AK5aar5R3eS9FucPy6rggvU0g52cvKId8pv4+v0c=
+go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
+go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI=
 go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
 go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
-go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
-go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
-go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
-go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
+go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
+go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
+go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
+go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
 go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
 go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
-go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
-go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
-go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
-go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
+go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
+go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
+go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os=
+go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo=
 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -417,12 +416,12 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/api v0.238.0 h1:+EldkglWIg/pWjkq97sd+XxH7PxakNYoe/rkSTbnvOs=
-google.golang.org/api v0.238.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
+google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo=
+google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
 google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78=
 google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk=
-google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0=
-google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto=
+google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
+google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
 google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
diff --git a/internal/units/units.go b/internal/units/units.go
index 6b0b5089..6ea337de 100644
--- a/internal/units/units.go
+++ b/internal/units/units.go
@@ -12,8 +12,8 @@
 
 //nolint:gochecknoglobals
 var (
-	base10UnitPrefixes = []string{"", "K", "M", "G", "T"}
-	base2UnitPrefixes  = []string{"", "Ki", "Mi", "Gi", "Ti"}
+	base10UnitPrefixes = []string{"", "K", "M", "G", "T", "P", "E"}
+	base2UnitPrefixes  = []string{"", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei"}
 )
 
 const (
diff --git a/repo/content/committed_content_index.go b/repo/content/committed_content_index.go
index 75b52e85..d479852b 100644
--- a/repo/content/committed_content_index.go
+++ b/repo/content/committed_content_index.go
@@ -42,8 +42,8 @@ type committedContentIndex struct {
 	v1PerContentOverhead func() int
 	formatProvider       format.Provider
 
-	// fetchOne loads one index blob
-	fetchOne func(ctx context.Context, blobID blob.ID, output *gather.WriteBuffer) error
+	// fetchIndexBlob retrieves one index blob from storage
+	fetchIndexBlob func(ctx context.Context, blobID blob.ID, output *gather.WriteBuffer) error
 
 	log logging.Logger
 }
@@ -321,7 +321,7 @@ func (c *committedContentIndex) fetchIndexBlobs(ctx context.Context, isPermissiv
 			for indexBlobID := range ch {
 				data.Reset()
 
-				if err := c.fetchOne(ctx, indexBlobID, &data); err != nil {
+				if err := c.fetchIndexBlob(ctx, indexBlobID, &data); err != nil {
 					if isPermissiveCacheLoading {
 						c.log.Errorf("skipping bad read of index blob %v", indexBlobID)
 						continue
@@ -371,7 +371,7 @@ func newCommittedContentIndex(caching *CachingOptions,
 	v1PerContentOverhead func() int,
 	formatProvider format.Provider,
 	permissiveCacheLoading bool,
-	fetchOne func(ctx context.Context, blobID blob.ID, output *gather.WriteBuffer) error,
+	fetchIndexBlob func(ctx context.Context, blobID blob.ID, output *gather.WriteBuffer) error,
 	log logging.Logger,
 	minSweepAge time.Duration,
 ) *committedContentIndex {
@@ -393,7 +393,7 @@ func newCommittedContentIndex(caching *CachingOptions,
 		inUse:                  map[blob.ID]index.Index{},
 		v1PerContentOverhead:   v1PerContentOverhead,
 		formatProvider:         formatProvider,
-		fetchOne:               fetchOne,
+		fetchIndexBlob:         fetchIndexBlob,
 		log:                    log,
 	}
 }
diff --git a/repo/maintenance/drop_deleted_contents.go b/repo/maintenance/drop_deleted_contents.go
index 9bb26ae9..3ed8d4ca 100644
--- a/repo/maintenance/drop_deleted_contents.go
+++ b/repo/maintenance/drop_deleted_contents.go
@@ -8,8 +8,8 @@
 	"github.com/kopia/kopia/repo/content/indexblob"
 )
 
-// DropDeletedContents rewrites indexes while dropping deleted contents above certain age.
-func DropDeletedContents(ctx context.Context, rep repo.DirectRepositoryWriter, dropDeletedBefore time.Time, safety SafetyParameters) error {
+// dropDeletedContents rewrites indexes while dropping deleted contents above certain age.
+func dropDeletedContents(ctx context.Context, rep repo.DirectRepositoryWriter, dropDeletedBefore time.Time, safety SafetyParameters) error {
 	log(ctx).Infof("Dropping contents deleted before %v", dropDeletedBefore)
 
 	//nolint:wrapcheck
diff --git a/repo/maintenance/maintenance_run.go b/repo/maintenance/maintenance_run.go
index f2638d67..0a3168c6 100644
--- a/repo/maintenance/maintenance_run.go
+++ b/repo/maintenance/maintenance_run.go
@@ -418,7 +418,7 @@ func runTaskDropDeletedContentsFull(ctx context.Context, runParams RunParameters
 	log(ctx).Infof("Found safe time to drop indexes: %v", safeDropTime)
 
 	return ReportRun(ctx, runParams.rep, TaskDropDeletedContentsFull, s, func() error {
-		return DropDeletedContents(ctx, runParams.rep, safeDropTime, safety)
+		return dropDeletedContents(ctx, runParams.rep, safeDropTime, safety)
 	})
 }
 
diff --git a/snapshot/manifest.go b/snapshot/manifest.go
index 5a8d44ba..8f3dcf68 100644
--- a/snapshot/manifest.go
+++ b/snapshot/manifest.go
@@ -3,6 +3,7 @@
 import (
 	"context"
 	"encoding/json"
+	"slices"
 	"sort"
 	"strconv"
 
@@ -236,11 +237,14 @@ func GroupBySource(manifests []*Manifest) [][]*Manifest {
 	return result
 }
 
-// SortByTime returns a slice of manifests sorted by start time.
+// SortByTime returns a slice of manifests sorted by start time, and end time as
+// the tie breaker when start time is the same.
 func SortByTime(manifests []*Manifest, reverse bool) []*Manifest {
-	result := append([]*Manifest(nil), manifests...)
+	result := slices.Clone(manifests)
 	sort.Slice(result, func(i, j int) bool {
-		return result[i].StartTime > result[j].StartTime == reverse
+		return (result[i].StartTime > result[j].StartTime ||
+			(result[i].StartTime == result[j].StartTime &&
+				result[i].EndTime > result[j].EndTime)) == reverse
 	})
 
 	return result
diff --git a/snapshot/upload/upload.go b/snapshot/upload/upload.go
index aef1f135..59395721 100644
--- a/snapshot/upload/upload.go
+++ b/snapshot/upload/upload.go
@@ -488,12 +488,10 @@ func newCachedDirEntry(md, cached fs.Entry, fname string) (*snapshot.DirEntry, e
 }
 
 // uploadFileWithCheckpointing uploads the specified File to the repository.
-func (u *Uploader) uploadFileWithCheckpointing(ctx context.Context, relativePath string, file fs.File, pol *policy.Policy, sourceInfo snapshot.SourceInfo, startTime fs.UTCTimestamp) (*snapshot.DirEntry, error) {
+func (u *Uploader) uploadFileWithCheckpointing(ctx context.Context, relativePath string, file fs.File, pol *policy.Policy, prototypeManifest *snapshot.Manifest) (*snapshot.DirEntry, error) {
 	var cp checkpointRegistry
 
-	// Make checkpoints one nanosecond older than the current snapshot, so that they are cleaned
-	// up by retention policies upon creation of the snapshot.
-	cancelCheckpointer := u.periodicallyCheckpoint(ctx, &cp, &snapshot.Manifest{Source: sourceInfo, StartTime: startTime - 1})
+	cancelCheckpointer := u.periodicallyCheckpoint(ctx, &cp, prototypeManifest)
 	defer cancelCheckpointer()
 
 	res, err := u.uploadFileInternal(ctx, &cp, relativePath, file, pol)
@@ -585,15 +583,13 @@ func (u *Uploader) periodicallyCheckpoint(ctx context.Context, cp *checkpointReg
 }
 
 // uploadDirWithCheckpointing uploads the specified Directory to the repository.
-func (u *Uploader) uploadDirWithCheckpointing(ctx context.Context, rootDir fs.Directory, policyTree *policy.Tree, previousDirs []fs.Directory, sourceInfo snapshot.SourceInfo, startTime fs.UTCTimestamp) (*snapshot.DirEntry, error) {
+func (u *Uploader) uploadDirWithCheckpointing(ctx context.Context, rootDir fs.Directory, policyTree *policy.Tree, previousDirs []fs.Directory, prototypeManifest *snapshot.Manifest) (*snapshot.DirEntry, error) {
 	var (
 		dmb snapshotfs.DirManifestBuilder
 		cp  checkpointRegistry
 	)
 
-	// Make checkpoints one nanosecond older than the current snapshot, so that they are cleaned
-	// up by retention policies upon creation of the snapshot.
-	cancelCheckpointer := u.periodicallyCheckpoint(ctx, &cp, &snapshot.Manifest{Source: sourceInfo, StartTime: startTime - 1})
+	cancelCheckpointer := u.periodicallyCheckpoint(ctx, &cp, prototypeManifest)
 	defer cancelCheckpointer()
 
 	var hc actionContext
@@ -1277,27 +1273,31 @@ func (u *Uploader) Upload(
 
 	uploadLog(ctx).Debugw("uploading", "source", sourceInfo, "previousManifests", len(previousManifests), "parallel", parallel)
 
-	s := &snapshot.Manifest{
-		Source: sourceInfo,
-	}
-
 	u.workerPool = workshare.NewPool[*uploadWorkItem](parallel - 1)
 	defer u.workerPool.Close()
 
+	s := snapshot.Manifest{
+		Source:    sourceInfo,
+		StartTime: fs.UTCTimestampFromTime(u.repo.Time()),
+	}
+
+	// prototypeMan is used to construct the manifests for the checkpoints
+	// and the final snapshot; it is passed using a pointer, however it should
+	// remain immutable.
+	prototypeMan := s
+
 	u.stats = &snapshot.Stats{}
 	u.totalWrittenBytes.Store(0)
 
 	var err error
 
-	s.StartTime = fs.UTCTimestampFromTime(u.repo.Time())
-
 	switch entry := source.(type) {
 	case fs.Directory:
-		s.RootEntry, err = u.uploadDir(ctx, previousManifests, entry, policyTree, sourceInfo, s.StartTime)
+		s.RootEntry, err = u.uploadDir(ctx, previousManifests, entry, policyTree, &prototypeMan)
 
 	case fs.File:
 		u.Progress.EstimatedDataSize(1, entry.Size())
-		s.RootEntry, err = u.uploadFileWithCheckpointing(ctx, entry.Name(), entry, policyTree.EffectivePolicy(), sourceInfo, s.StartTime)
+		s.RootEntry, err = u.uploadFileWithCheckpointing(ctx, entry.Name(), entry, policyTree.EffectivePolicy(), &prototypeMan)
 
 	default:
 		return nil, errors.Errorf("unsupported source: %v", s.Source)
@@ -1311,7 +1311,7 @@ func (u *Uploader) Upload(
 	s.EndTime = fs.UTCTimestampFromTime(u.repo.Time())
 	s.Stats = *u.stats
 
-	return s, nil
+	return &s, nil
 }
 
 func (u *Uploader) uploadDir(
@@ -1319,8 +1319,7 @@ func (u *Uploader) uploadDir(
 	previousManifests []*snapshot.Manifest,
 	entry fs.Directory,
 	policyTree *policy.Tree,
-	sourceInfo snapshot.SourceInfo,
-	startTime fs.UTCTimestamp,
+	prototypeManifest *snapshot.Manifest,
 ) (*snapshot.DirEntry, error) {
 	var previousDirs []fs.Directory
 
@@ -1338,7 +1337,7 @@ func (u *Uploader) uploadDir(
 
 	wrapped := u.wrapIgnorefs(uploadLog(ctx), entry, policyTree, true /* reportIgnoreStats */)
 
-	return u.uploadDirWithCheckpointing(ctx, wrapped, policyTree, previousDirs, sourceInfo, startTime)
+	return u.uploadDirWithCheckpointing(ctx, wrapped, policyTree, previousDirs, prototypeManifest)
 }
 
 func (u *Uploader) startDataSizeEstimation(


Test patch:
diff --git a/internal/repotesting/repotesting_test.go b/internal/repotesting/repotesting_test.go
index 53e6a25e..38d4f693 100644
--- a/internal/repotesting/repotesting_test.go
+++ b/internal/repotesting/repotesting_test.go
@@ -24,9 +24,7 @@ func TestTimeFuncWiring(t *testing.T) {
 
 	// Re open with injected time
 	rep, err := repo.Open(ctx, env.RepositoryWriter.ConfigFilename(), env.Password, &repo.Options{TimeNowFunc: ft.NowFunc()})
-	if err != nil {
-		t.Fatal("Failed to open repo:", err)
-	}
+	require.NoError(t, err, "failed to open test repository")
 
 	r0 := testutil.EnsureType[repo.DirectRepository](t, rep)
 
@@ -34,30 +32,22 @@ func TestTimeFuncWiring(t *testing.T) {
 	require.NoError(t, err)
 
 	// verify wiring for the repo layer
-	if got, want := env.RepositoryWriter.Time(), ft.NowFunc()(); !got.Equal(want) {
-		t.Errorf("times don't match, got %v, want %v", got, want)
-	}
+	got, want := env.RepositoryWriter.Time(), ft.NowFunc()()
+	require.WithinDuration(t, want, got, 0, "times do not match")
 
-	if want, got := ft.Advance(10*time.Minute), env.RepositoryWriter.Time(); !got.Equal(want) {
-		t.Errorf("times don't match, got %v, want %v", got, want)
-	}
+	want, got = ft.Advance(10*time.Minute), env.RepositoryWriter.Time()
+	require.WithinDuration(t, want, got, 0, "times do not match")
 
 	// verify wiring for the content layer
 	nt := ft.Advance(20 * time.Second)
 
 	cid, err := env.RepositoryWriter.ContentManager().WriteContent(ctx, gather.FromSlice([]byte("foo")), "", content.NoCompression)
-	if err != nil {
-		t.Fatal("failed to write content:", err)
-	}
+	require.NoError(t, err, "failed to write content")
 
 	info, err := env.RepositoryWriter.ContentInfo(ctx, cid)
-	if err != nil {
-		t.Fatal("failed to get content info for", cid, err)
-	}
 
-	if got, want := info.Timestamp(), nt; !got.Equal(want) {
-		t.Errorf("content time does not match, got %v, want %v", got, want)
-	}
+	require.NoErrorf(t, err, "failed to get content info for %s", cid)
+	require.WithinDuration(t, nt, info.Timestamp(), 0, "content time does not match")
 
 	// verify wiring for the manifest layer
 	nt = ft.Advance(3 * time.Minute)
@@ -65,18 +55,12 @@ func TestTimeFuncWiring(t *testing.T) {
 	labels := map[string]string{"l1": "v1", "l2": "v2", "type": "my-manifest"}
 
 	mid, err := env.RepositoryWriter.PutManifest(ctx, labels, "manifest content")
-	if err != nil {
-		t.Fatal("failed to put manifest:", err)
-	}
+	require.NoError(t, err, "failed to put manifest")
 
 	meta, err := env.RepositoryWriter.GetManifest(ctx, mid, nil)
-	if err != nil {
-		t.Fatal("failed to get manifest metadata:", err)
-	}
 
-	if got, want := meta.ModTime, nt; !got.Equal(want) {
-		t.Errorf("manifest time does not match, got %v, want %v", got, want)
-	}
+	require.NoError(t, err, "failed to get manifest metadata")
+	require.WithinDuration(t, nt, meta.ModTime, 0, "manifest modification time does not match")
 
 	const defaultPermissions = 0o777
 
@@ -89,11 +73,7 @@ func TestTimeFuncWiring(t *testing.T) {
 	policyTree := policy.BuildTree(nil, policy.DefaultPolicy)
 
 	s1, err := u.Upload(ctx, sourceDir, policyTree, snapshot.SourceInfo{})
-	if err != nil {
-		t.Fatal("failed to create snapshot:", err)
-	}
 
-	if got, want := nt, s1.StartTime.ToTime(); !got.Equal(want) {
-		t.Fatalf("snapshot time does not match, got: %v, want: %v", got, want)
-	}
+	require.NoError(t, err, "failed to create snapshot")
+	require.WithinDuration(t, nt, s1.StartTime.ToTime(), 0, "snapshot time does not match")
 }
diff --git a/internal/units/units_test.go b/internal/units/units_test.go
index f0893abf..aa892f81 100644
--- a/internal/units/units_test.go
+++ b/internal/units/units_test.go
@@ -1,12 +1,14 @@
 package units
 
 import (
-	"os"
+	"fmt"
 	"testing"
+
+	"github.com/stretchr/testify/require"
 )
 
 var base10Cases = []struct {
-	value    int64
+	value    uint64
 	expected string
 }{
 	{0, "0 B"},
@@ -28,10 +30,19 @@
 	{99900000000, "99.9 GB"},
 	{1000000000000, "1 TB"},
 	{99000000000000, "99 TB"},
+	{900000000000000 - 1, "900 TB"},
+	{900000000000000, "0.9 PB"},
+	{990000000000000, "1 PB"},
+	{1000000000000000, "1 PB"},
+	{800000000000000000, "800 PB"},
+	{900000000000000000, "0.9 EB"},
+	{990000000000000000, "1 EB"},
+	{1000000000000000000, "1 EB"},
+	{10000000000000000000, "10 EB"},
 }
 
 var base2Cases = []struct {
-	value    int64
+	value    uint64
 	expected string
 }{
 	{0, "0 B"},
@@ -52,45 +63,61 @@
 	{10 << 30, "10 GiB"},
 	{99900000000, "93 GiB"},
 	{1000000000000, "0.9 TiB"},
+	{1000000000000, "0.9 TiB"},
+	{1 << 40, "1 TiB"},
+	{10 << 40, "10 TiB"},
 	{99000000000000, "90 TiB"},
+	{900 << 40, "900 TiB"},
+	{(950 << 40), "0.9 PiB"},
+	{990 << 40, "1 PiB"},
+
+	{1 << 50, "1 PiB"},
+	{10 << 50, "10 PiB"},
+	{90 << 50, "90 PiB"},
+	{900 << 50, "900 PiB"},
+	{(950 << 50), "0.9 EiB"},
+	{990 << 50, "1 EiB"},
+	{1 << 60, "1 EiB"},
+	{10 << 60, "10 EiB"},
+	{15 << 60, "15 EiB"},
 }
 
 func TestBytesStringBase10(t *testing.T) {
 	for i, c := range base10Cases {
-		actual := BytesStringBase10(c.value)
-		if actual != c.expected {
-			t.Errorf("case #%v failed for %v, expected: '%v', got '%v'", i, c.value, c.expected, actual)
-		}
+		t.Run(fmt.Sprint(i, "-", c.value), func(t *testing.T) {
+			actual := BytesStringBase10(c.value)
+			require.Equal(t, c.expected, actual)
+		})
 	}
 }
 
 func TestBytesStringBase2(t *testing.T) {
 	for i, c := range base2Cases {
-		actual := BytesStringBase2(c.value)
-		if actual != c.expected {
-			t.Errorf("case #%v failed for %v, expected: '%v', got '%v'", i, c.value, c.expected, actual)
-		}
+		t.Run(fmt.Sprint(i, "-", c.value), func(t *testing.T) {
+			actual := BytesStringBase2(c.value)
+			require.Equal(t, c.expected, actual)
+		})
 	}
 }
 
-func TestBytesString(t *testing.T) {
-	defer os.Unsetenv(bytesStringBase2Envar)
-
+func TestBytesString_base2EnvFalse(t *testing.T) {
 	t.Setenv(bytesStringBase2Envar, "false")
 
 	for i, c := range base10Cases {
-		actual := BytesString(c.value)
-		if actual != c.expected {
-			t.Errorf("case #%v failed for %v, expected: '%v', got '%v'", i, c.value, c.expected, actual)
-		}
+		t.Run(fmt.Sprint(i, "-", c.value), func(t *testing.T) {
+			actual := BytesString(c.value)
+			require.Equal(t, c.expected, actual)
+		})
 	}
+}
 
+func TestBytesString_base2EnvTrue(t *testing.T) {
 	t.Setenv(bytesStringBase2Envar, "true")
 
 	for i, c := range base2Cases {
-		actual := BytesString(c.value)
-		if actual != c.expected {
-			t.Errorf("case #%v failed for %v, expected: '%v', got '%v'", i, c.value, c.expected, actual)
-		}
+		t.Run(fmt.Sprint(i, "-", c.value), func(t *testing.T) {
+			actual := BytesString(c.value)
+			require.Equal(t, c.expected, actual)
+		})
 	}
 }
diff --git a/snapshot/snapshot_test.go b/snapshot/snapshot_test.go
index 77c22bae..edf41c26 100644
--- a/snapshot/snapshot_test.go
+++ b/snapshot/snapshot_test.go
@@ -7,9 +7,11 @@
 	"reflect"
 	"sort"
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/require"
 
+	"github.com/kopia/kopia/fs"
 	"github.com/kopia/kopia/internal/repotesting"
 	"github.com/kopia/kopia/internal/testlogging"
 	"github.com/kopia/kopia/repo"
@@ -270,3 +272,79 @@ func TestUpdatePins(t *testing.T) {
 	require.False(t, m.UpdatePins([]string{"e", "a"}, []string{"c"}))
 	require.Equal(t, []string{"a", "b", "d", "e"}, m.Pins)
 }
+
+// Helper to create a Manifest with given times.
+func newManifest(start, end time.Time) *snapshot.Manifest {
+	return &snapshot.Manifest{
+		StartTime: fs.UTCTimestampFromTime(start),
+		EndTime:   fs.UTCTimestampFromTime(end),
+	}
+}
+
+func TestSortByTimeAscending(t *testing.T) {
+	start0 := time.Date(2018, time.January, 10, 13, 23, 21, 0, time.UTC)
+	start1 := start0.Add(3 * time.Hour)
+	start2 := time.Date(2018, time.January, 10, 13, 30, 1, 0, time.UTC)
+	start3 := start2
+	start4 := time.Date(2018, time.January, 10, 14, 0, 1, 0, time.UTC)
+
+	end1 := start1.Add(20 * time.Second)
+	end2 := start2.Add(40 * time.Second)
+	end3 := start3.Add(2 * time.Minute)
+	end4 := start4.Add(5 * time.Minute)
+	end0 := start0.Add(1000 * time.Hour) // overlaps with other snapshots
+
+	manifests := []*snapshot.Manifest{
+		newManifest(start2, end2),
+		newManifest(start3, end3), // same start time as start2, later end time
+		newManifest(start1, end1),
+		newManifest(start0, end0),
+		newManifest(start4, end4),
+	}
+
+	// Test normal sort (most recent last)
+	sorted := snapshot.SortByTime(manifests, false)
+
+	prev := sorted[0]
+	for _, s := range sorted[1:] {
+		require.LessOrEqual(t, prev.StartTime, s.StartTime, "start time is after")
+
+		if prev.StartTime == s.StartTime {
+			require.LessOrEqual(t, prev.EndTime, s.EndTime, "end time is after")
+		}
+	}
+}
+
+func TestSortByTimeDescending(t *testing.T) {
+	start0 := time.Date(2018, time.January, 10, 13, 23, 21, 0, time.UTC)
+	start1 := start0.Add(3 * time.Hour)
+	start2 := time.Date(2018, time.January, 10, 13, 30, 1, 0, time.UTC)
+	start3 := start2
+	start4 := time.Date(2018, time.January, 10, 14, 0, 1, 0, time.UTC)
+
+	end1 := start1.Add(20 * time.Second)
+	end2 := start2.Add(40 * time.Second)
+	end3 := start3.Add(2 * time.Minute)
+	end4 := start4.Add(5 * time.Minute)
+	end0 := start0.Add(1000 * time.Hour) // overlaps with other snapshots
+
+	manifests := []*snapshot.Manifest{
+		newManifest(start2, end2),
+		newManifest(start3, end3), // same start time as start2, later end time
+		newManifest(start1, end1),
+		newManifest(start0, end0),
+		newManifest(start4, end4),
+	}
+
+	// Test reverse sort (most recent first)
+	sorted := snapshot.SortByTime(manifests, true)
+
+	prev := sorted[0]
+	for _, s := range sorted[1:] {
+		require.GreaterOrEqual(t, prev.StartTime, s.StartTime, "start time before after")
+
+		if prev.StartTime == s.StartTime {
+			require.GreaterOrEqual(t, prev.EndTime, s.EndTime, "end time is after")
+		}
+	}
+}
diff --git a/snapshot/upload/upload_test.go b/snapshot/upload/upload_test.go
index 50044a74..7519b03d 100644
--- a/snapshot/upload/upload_test.go
+++ b/snapshot/upload/upload_test.go
@@ -793,7 +793,8 @@ func TestUploadWithCheckpointing(t *testing.T) {
 
 	for _, cp := range checkpoints {
 		assert.Equal(t, IncompleteReasonCheckpoint, cp.IncompleteReason, "unexpected incompleteReason")
-		assert.Equal(t, time.Duration(1), s.StartTime.Sub(cp.StartTime), "snapshot start time is expected to be after checkpoint time")
+		assert.Equal(t, s.StartTime, cp.StartTime, "checkpoint start time is expected to match snapshot time")
+		assert.Falsef(t, s.EndTime.Before(cp.EndTime), "snapshot end time (%s) is before checkpoint end time (%s), snapshot end time should be equal or after checkpoint time ", s.EndTime.Format(time.RFC3339Nano), cp.EndTime.Format(time.RFC3339Nano))
 		assert.Equal(t, labels, cp.Tags)
 	}
 }