Repository: kopia/kopia
Pull request #4559 (2025-05-05 23:51:09)

feat(cli): helper functions to get preceding snapshots for `diff`
Added helper functions to find preceding snapshots.

Part of the planned enhancements for the `diff` command.

Added the helper methods to fetch preceding snapshots. These helpers will be consumed to enhance the CLI for the existing `diff` command implementation.
- Method to fetch the immediately preceding snapshot manifest given a snapshot ID
- Method to fetch the two latest snapshot manifests for a given source

Also added relevant unit tests for the above methods:

- In order to test `GetPrecedingSnapshot`, first the method is invoked with a wrong `snapshotID` and an error is expected. Next a snapshot is saved and the method is invoked again, since there is only a single snapshot in the repo, an error is expected. Subsequently more snapshots are saved and the method is invoked and the immediately preceding snapshot based on timestamp is expected
- In order to test `GetTwoLatestSnapshotsForASource`, a similar strategy is used. The method is invoked with insufficient snapshots stored in the repo and errors are expected. Eventually more snapshots are saved and when the method is invoked the two most recent manifests for snapshot based on start time are expected

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/kopia/kopia/pull/4559?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) Report
Attention: Patch coverage is `80.55556%` with `7 lines` in your changes missing coverage. Please review.
> Project coverage is 76.36%. Comparing base [(`cb455c6`)](https://app.codecov.io/gh/kopia/kopia/commit/cb455c699a04e54cb47e25a676d7d65063ce883e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) to head [(`e355223`)](https://app.codecov.io/gh/kopia/kopia/commit/e3552233d814e747d221b5d0abfc40ece0ed6004?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).
> Report is 502 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/kopia/kopia/pull/4559?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) | Patch % | Lines |
|---|---|---|
| [internal/diff/diff.go](https://app.codecov.io/gh/kopia/kopia/pull/4559?src=pr&el=tree&filepath=internal%2Fdiff%2Fdiff.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-aW50ZXJuYWwvZGlmZi9kaWZmLmdv) | 79.41% | [5 Missing and 2 partials :warning: ](https://app.codecov.io/gh/kopia/kopia/pull/4559?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #4559      +/-   ##
==========================================
+ Coverage   75.86%   76.36%   +0.50%     
==========================================
  Files         470      527      +57     
  Lines       37301    40084    +2783     
==========================================
+ Hits        28299    30611    +2312     
- Misses       7071     7453     +382     
- Partials     1931     2020      +89     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/kopia/kopia/pull/4559?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>

Patch:
diff --git a/cli/command_diff.go b/cli/command_diff.go
index c3d9f6e2..c8033dce 100644
--- a/cli/command_diff.go
+++ b/cli/command_diff.go
@@ -18,6 +18,7 @@ type commandDiff struct {
 	diffFirstObjectPath  string
 	diffSecondObjectPath string
 	diffCompareFiles     bool
+	diffStatsOnly        bool
 	diffCommandCommand   string
 
 	out textOutput
@@ -28,6 +29,7 @@ func (c *commandDiff) setup(svc appServices, parent commandParent) {
 	cmd.Arg("object-path1", "First object/path").Required().StringVar(&c.diffFirstObjectPath)
 	cmd.Arg("object-path2", "Second object/path").Required().StringVar(&c.diffSecondObjectPath)
 	cmd.Flag("files", "Compare files by launching diff command for all pairs of (old,new)").Short('f').BoolVar(&c.diffCompareFiles)
+	cmd.Flag("stats-only", "Displays only aggregate statistics of the changes between two repository objects").BoolVar(&c.diffStatsOnly)
 	cmd.Flag("diff-command", "Displays differences between two repository objects (files or directories)").Default(defaultDiffCommand()).Envar(svc.EnvName("KOPIA_DIFF")).StringVar(&c.diffCommandCommand)
 	cmd.Action(svc.repositoryReaderAction(c.run))
 
@@ -52,7 +54,7 @@ func (c *commandDiff) run(ctx context.Context, rep repo.Repository) error {
 		return errors.New("arguments to diff must both be directories or both non-directories")
 	}
 
-	d, err := diff.NewComparer(c.out.stdout())
+	d, err := diff.NewComparer(c.out.stdout(), c.diffStatsOnly)
 	if err != nil {
 		return errors.Wrap(err, "error creating comparer")
 	}
diff --git a/cli/command_maintenance_set.go b/cli/command_maintenance_set.go
index 4dbd6e81..752b4286 100644
--- a/cli/command_maintenance_set.go
+++ b/cli/command_maintenance_set.go
@@ -93,7 +93,7 @@ func (c *commandMaintenanceSet) setLogCleanupParametersFromFlags(ctx context.Con
 	}
 }
 
-func (c *commandMaintenanceSet) setDeleteUnreferencedBlobsParams(ctx context.Context, p *maintenance.Params, changed *bool) {
+func (c *commandMaintenanceSet) setListBlobsParallelismFromFlags(ctx context.Context, p *maintenance.Params, changed *bool) {
 	if v := c.listParallelism; v != -1 {
 		p.ListParallelism = v
 		*changed = true
@@ -172,7 +172,7 @@ func (c *commandMaintenanceSet) run(ctx context.Context, rep repo.DirectReposito
 	c.setMaintenanceEnabledAndIntervalFromFlags(ctx, &p.QuickCycle, "quick", c.maintenanceSetEnableQuick, c.maintenanceSetQuickFrequency, &changedParams)
 	c.setMaintenanceEnabledAndIntervalFromFlags(ctx, &p.FullCycle, "full", c.maintenanceSetEnableFull, c.maintenanceSetFullFrequency, &changedParams)
 	c.setLogCleanupParametersFromFlags(ctx, p, &changedParams)
-	c.setDeleteUnreferencedBlobsParams(ctx, p, &changedParams)
+	c.setListBlobsParallelismFromFlags(ctx, p, &changedParams)
 	c.setMaintenanceObjectLockExtendFromFlags(ctx, p, &changedParams)
 
 	if pauseDuration := c.maintenanceSetPauseQuick; pauseDuration != -1 {
diff --git a/cli/command_policy_show.go b/cli/command_policy_show.go
index d197b4a6..f62a0d89 100644
--- a/cli/command_policy_show.go
+++ b/cli/command_policy_show.go
@@ -482,7 +482,11 @@ func appendActionCommandRows(rows []policyTableRow, h *policy.ActionCommand) []p
 func appendOSSnapshotPolicyRows(rows []policyTableRow, p *policy.Policy, def *policy.Definition) []policyTableRow {
 	rows = append(rows,
 		policyTableRow{"OS-level snapshot support:", "", ""},
-		policyTableRow{"  Volume Shadow Copy:", p.OSSnapshotPolicy.VolumeShadowCopy.Enable.String(), definitionPointToString(p.Target(), def.OSSnapshotPolicy.VolumeShadowCopy.Enable)},
+		policyTableRow{
+			"  Volume Shadow Copy:",
+			p.OSSnapshotPolicy.VolumeShadowCopy.Enable.OrDefault(policy.OSSnapshotNever).String(),
+			definitionPointToString(p.Target(), def.OSSnapshotPolicy.VolumeShadowCopy.Enable),
+		},
 	)
 
 	return rows
diff --git a/go.mod b/go.mod
index 56706e3a..c8f8a8c9 100644
--- a/go.mod
+++ b/go.mod
@@ -2,10 +2,10 @@ module github.com/kopia/kopia
 
 go 1.23.0
 
-toolchain go1.23.8
+toolchain go1.23.9
 
 require (
-	cloud.google.com/go/storage v1.52.0
+	cloud.google.com/go/storage v1.54.0
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0
 	github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0
 	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1
@@ -30,7 +30,7 @@ require (
 	github.com/klauspost/compress v1.18.0
 	github.com/klauspost/pgzip v1.2.6
 	github.com/klauspost/reedsolomon v1.12.4
-	github.com/kopia/htmluibuild v0.0.1-0.20250502013900-9ddd3cdab3fc
+	github.com/kopia/htmluibuild v0.0.1-0.20250512051948-f1295f5318a4
 	github.com/kylelemons/godebug v1.1.0
 	github.com/mattn/go-colorable v0.1.14
 	github.com/mattn/go-isatty v0.0.20
@@ -58,16 +58,16 @@ require (
 	go.opentelemetry.io/otel/sdk v1.35.0
 	go.opentelemetry.io/otel/trace v1.35.0
 	go.uber.org/zap v1.27.0
-	golang.org/x/crypto v0.37.0
+	golang.org/x/crypto v0.38.0
 	golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1
 	golang.org/x/mod v0.24.0
-	golang.org/x/net v0.39.0
-	golang.org/x/oauth2 v0.29.0
-	golang.org/x/sync v0.13.0
-	golang.org/x/sys v0.32.0
-	golang.org/x/term v0.31.0
-	golang.org/x/text v0.24.0
-	google.golang.org/api v0.230.0
+	golang.org/x/net v0.40.0
+	golang.org/x/oauth2 v0.30.0
+	golang.org/x/sync v0.14.0
+	golang.org/x/sys v0.33.0
+	golang.org/x/term v0.32.0
+	golang.org/x/text v0.25.0
+	google.golang.org/api v0.232.0
 	google.golang.org/grpc v1.72.0
 	google.golang.org/protobuf v1.36.6
 	gopkg.in/kothar/go-backblaze.v0 v0.0.0-20210124194846-35409b867216
@@ -76,11 +76,11 @@ require (
 require (
 	al.essio.dev/pkg/shellescape v1.5.1 // indirect
 	cel.dev/expr v0.20.0 // indirect
-	cloud.google.com/go v0.120.0 // indirect
-	cloud.google.com/go/auth v0.16.0 // indirect
+	cloud.google.com/go v0.121.0 // indirect
+	cloud.google.com/go/auth v0.16.1 // indirect
 	cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
 	cloud.google.com/go/compute/metadata v0.6.0 // indirect
-	cloud.google.com/go/iam v1.5.0 // indirect
+	cloud.google.com/go/iam v1.5.2 // indirect
 	cloud.google.com/go/monitoring v1.24.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
 	github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
@@ -145,7 +145,7 @@ require (
 	go.uber.org/multierr v1.11.0 // indirect
 	golang.org/x/time v0.11.0 // indirect
 	google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/go.sum b/go.sum
index ade269c2..b43b59db 100644
--- a/go.sum
+++ b/go.sum
@@ -2,24 +2,24 @@ al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXy
 al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
 cel.dev/expr v0.20.0 h1:OunBvVCfvpWlt4dN7zg3FM6TDkzOePe1+foGJ9AXeeI=
 cel.dev/expr v0.20.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=
-cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA=
-cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q=
-cloud.google.com/go/auth v0.16.0 h1:Pd8P1s9WkcrBE2n/PhAwKsdrR35V3Sg2II9B+ndM3CU=
-cloud.google.com/go/auth v0.16.0/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI=
+cloud.google.com/go v0.121.0 h1:pgfwva8nGw7vivjZiRfrmglGWiCJBP+0OmDpenG/Fwg=
+cloud.google.com/go v0.121.0/go.mod h1:rS7Kytwheu/y9buoDmu5EIpMMCI4Mb8ND4aeN4Vwj7Q=
+cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU=
+cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI=
 cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
 cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
 cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
 cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
-cloud.google.com/go/iam v1.5.0 h1:QlLcVMhbLGOjRcGe6VTGGTyQib8dRLK2B/kYNV0+2xs=
-cloud.google.com/go/iam v1.5.0/go.mod h1:U+DOtKQltF/LxPEtcDLoobcsZMilSRwR7mgNL7knOpo=
+cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
+cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=
 cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
 cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA=
-cloud.google.com/go/longrunning v0.6.6 h1:XJNDo5MUfMM05xK3ewpbSdmt7R2Zw+aQEMbdQR65Rbw=
-cloud.google.com/go/longrunning v0.6.6/go.mod h1:hyeGJUrPHcx0u2Uu1UFSoYZLn4lkMrccJig0t4FI7yw=
+cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
+cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
 cloud.google.com/go/monitoring v1.24.0 h1:csSKiCJ+WVRgNkRzzz3BPoGjFhjPY23ZTcaenToJxMM=
 cloud.google.com/go/monitoring v1.24.0/go.mod h1:Bd1PRK5bmQBQNnuGwHBfUamAV1ys9049oEPHnn4pcsc=
-cloud.google.com/go/storage v1.52.0 h1:ROpzMW/IwipKtatA69ikxibdzQSiXJrY9f6IgBa9AlA=
-cloud.google.com/go/storage v1.52.0/go.mod h1:4wrBAbAYUvYkbrf19ahGm4I5kDQhESSqN3CGEkMGvOY=
+cloud.google.com/go/storage v1.54.0 h1:Du3XEyliAiftfyW0bwfdppm2MMLdpVAfiIg4T2nAI+0=
+cloud.google.com/go/storage v1.54.0/go.mod h1:hIi9Boe8cHxTyaeqh7KMMwKg088VblFK46C2x/BWaZE=
 cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE=
 cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8=
 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
@@ -184,8 +184,8 @@ github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU
 github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
 github.com/klauspost/reedsolomon v1.12.4 h1:5aDr3ZGoJbgu/8+j45KtUJxzYm8k08JGtB9Wx1VQ4OA=
 github.com/klauspost/reedsolomon v1.12.4/go.mod h1:d3CzOMOt0JXGIFZm1StgkyF14EYr3xneR2rNWo7NcMU=
-github.com/kopia/htmluibuild v0.0.1-0.20250502013900-9ddd3cdab3fc h1:Ok5ifVV/rVVkA1kO9lElEbJRhjqZuPhP+yC9TZqec/4=
-github.com/kopia/htmluibuild v0.0.1-0.20250502013900-9ddd3cdab3fc/go.mod h1:h53A5JM3t2qiwxqxusBe+PFgGcgZdS+DWCQvG5PTlto=
+github.com/kopia/htmluibuild v0.0.1-0.20250512051948-f1295f5318a4 h1:YatTOPU1vS0RKWggABuoHWG7TfmYX6tUGWmLbJ8PwLg=
+github.com/kopia/htmluibuild v0.0.1-0.20250512051948-f1295f5318a4/go.mod h1:h53A5JM3t2qiwxqxusBe+PFgGcgZdS+DWCQvG5PTlto=
 github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -331,8 +331,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
 golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
 golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
 golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
-golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
+golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
+golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
 golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=
 golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -351,10 +351,10 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
 golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
 golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
 golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
-golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
-golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
-golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
-golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
+golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
+golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
+golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
+golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -362,8 +362,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
 golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
-golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
+golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -379,8 +379,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
-golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -390,8 +390,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
 golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
 golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
 golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
-golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
-golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
+golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
+golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -401,8 +401,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
-golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
+golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
+golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
 golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
 golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -413,14 +413,14 @@ 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.230.0 h1:2u1hni3E+UXAXrONrrkfWpi/V6cyKVAbfGVeGtC3OxM=
-google.golang.org/api v0.230.0/go.mod h1:aqvtoMk7YkiXx+6U12arQFExiRV9D/ekvMCwCd/TksQ=
+google.golang.org/api v0.232.0 h1:qGnmaIMf7KcuwHOlF3mERVzChloDYwRfOJOrHt8YC3I=
+google.golang.org/api v0.232.0/go.mod h1:p9QCfBWZk1IJETUdbTKloR5ToFdKbYh2fkjsUL6vNoY=
 google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE=
 google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE=
-google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e h1:UdXH7Kzbj+Vzastr5nVfccbmFsmYNygVLSPk1pEfDoY=
-google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e/go.mod h1:085qFyf2+XaZlRdCgKNCIZ3afY2p4HHZdoIRpId8F4A=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:ztQaXfzEXTmCBvbtWYRhJxW+0iJcz2qXfd38/e9l7bA=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
+google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 h1:vPV0tzlsK6EzEDHNNH5sa7Hs9bd7iXR7B1tSiPepkV0=
+google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 h1:IqsN8hx+lWLqlN+Sc3DoMy/watjofWiU8sRFgQ8fhKM=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
 google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM=
 google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
 google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
diff --git a/internal/diff/diff.go b/internal/diff/diff.go
index 39fe0be6..582e2e7e 100644
--- a/internal/diff/diff.go
+++ b/internal/diff/diff.go
@@ -8,13 +8,17 @@
 	"os"
 	"os/exec"
 	"path/filepath"
+	"sort"
 
 	"github.com/pkg/errors"
 
 	"github.com/kopia/kopia/fs"
 	"github.com/kopia/kopia/internal/iocopy"
+	"github.com/kopia/kopia/repo"
 	"github.com/kopia/kopia/repo/logging"
 	"github.com/kopia/kopia/repo/object"
+	"github.com/kopia/kopia/snapshot"
+	"github.com/kopia/kopia/snapshot/snapshotfs"
 )
 
 const dirMode = 0o700
@@ -48,6 +52,7 @@ type Comparer struct {
 	stats         Stats
 	out           io.Writer
 	tmpDir        string
+	statsOnly     bool
 	DiffCommand   string
 	DiffArguments []string
 }
@@ -122,14 +127,14 @@ func (c *Comparer) compareEntry(ctx context.Context, e1, e2 fs.Entry, path strin
 
 	if e1 == nil {
 		if dir2, isDir2 := e2.(fs.Directory); isDir2 {
-			c.output("added directory %v\n", path)
+			c.output(c.statsOnly, "added directory %v\n", path)
 
 			c.stats.DirectoryEntries.Added++
 
 			return c.compareDirectories(ctx, nil, dir2, path)
 		}
 
-		c.output("added file %v (%v bytes)\n", path, e2.Size())
+		c.output(c.statsOnly, "added file %v (%v bytes)\n", path, e2.Size())
 
 		c.stats.FileEntries.Added++
 
@@ -144,14 +149,14 @@ func (c *Comparer) compareEntry(ctx context.Context, e1, e2 fs.Entry, path strin
 
 	if e2 == nil {
 		if dir1, isDir1 := e1.(fs.Directory); isDir1 {
-			c.output("removed directory %v\n", path)
+			c.output(c.statsOnly, "removed directory %v\n", path)
 
 			c.stats.DirectoryEntries.Removed++
 
 			return c.compareDirectories(ctx, dir1, nil, path)
 		}
 
-		c.output("removed file %v (%v bytes)\n", path, e1.Size())
+		c.output(c.statsOnly, "removed file %v (%v bytes)\n", path, e1.Size())
 
 		c.stats.FileEntries.Removed++
 
@@ -172,7 +177,7 @@ func (c *Comparer) compareEntry(ctx context.Context, e1, e2 fs.Entry, path strin
 	if isDir1 {
 		if !isDir2 {
 			// right is a non-directory, left is a directory
-			c.output("changed %v from directory to non-directory\n", path)
+			c.output(c.statsOnly, "changed %v from directory to non-directory\n", path)
 			return nil
 		}
 
@@ -181,14 +186,14 @@ func (c *Comparer) compareEntry(ctx context.Context, e1, e2 fs.Entry, path strin
 
 	if isDir2 {
 		// left is non-directory, right is a directory
-		c.output("changed %v from non-directory to a directory\n", path)
+		c.output(c.statsOnly, "changed %v from non-directory to a directory\n", path)
 
 		return nil
 	}
 
 	if f1, ok := e1.(fs.File); ok {
 		if f2, ok := e2.(fs.File); ok {
-			c.output("changed %v at %v (size %v -> %v)\n", path, e2.ModTime().String(), e1.Size(), e2.Size())
+			c.output(c.statsOnly, "changed %v at %v (size %v -> %v)\n", path, e2.ModTime().String(), e1.Size(), e2.Size())
 
 			c.stats.FileEntries.Modified++
 
@@ -242,10 +247,10 @@ func (c *Comparer) compareEntryMetadata(e1, e2 fs.Entry, fullpath string) {
 	case e1 == e2: // in particular e1 == nil && e2 == nil
 		return
 	case e1 == nil:
-		c.output("%v does not exist in source directory\n", fullpath)
+		c.output(c.statsOnly, "%v does not exist in source directory\n", fullpath)
 		return
 	case e2 == nil:
-		c.output("%v does not exist in destination directory\n", fullpath)
+		c.output(c.statsOnly, "%v does not exist in destination directory\n", fullpath)
 		return
 	}
 
@@ -254,32 +259,32 @@ func (c *Comparer) compareEntryMetadata(e1, e2 fs.Entry, fullpath string) {
 	if m1, m2 := e1.Mode(), e2.Mode(); m1 != m2 {
 		changed = true
 
-		c.output("%v modes differ: %v %v\n", fullpath, m1, m2)
+		c.output(c.statsOnly, "%v modes differ: %v %v\n", fullpath, m1, m2)
 	}
 
 	if s1, s2 := e1.Size(), e2.Size(); s1 != s2 {
 		changed = true
 
-		c.output("%v sizes differ: %v %v\n", fullpath, s1, s2)
+		c.output(c.statsOnly, "%v sizes differ: %v %v\n", fullpath, s1, s2)
 	}
 
 	if mt1, mt2 := e1.ModTime(), e2.ModTime(); !mt1.Equal(mt2) {
 		changed = true
 
-		c.output("%v modification times differ: %v %v\n", fullpath, mt1, mt2)
+		c.output(c.statsOnly, "%v modification times differ: %v %v\n", fullpath, mt1, mt2)
 	}
 
 	o1, o2 := e1.Owner(), e2.Owner()
 	if o1.UserID != o2.UserID {
 		changed = true
 
-		c.output("%v owner users differ: %v %v\n", fullpath, o1.UserID, o2.UserID)
+		c.output(c.statsOnly, "%v owner users differ: %v %v\n", fullpath, o1.UserID, o2.UserID)
 	}
 
 	if o1.GroupID != o2.GroupID {
 		changed = true
 
-		c.output("%v owner groups differ: %v %v\n", fullpath, o1.GroupID, o2.GroupID)
+		c.output(c.statsOnly, "%v owner groups differ: %v %v\n", fullpath, o1.GroupID, o2.GroupID)
 	}
 
 	_, isDir1 := e1.(fs.Directory)
@@ -391,16 +396,69 @@ func (c *Comparer) Stats() Stats {
 	return c.stats
 }
 
-func (c *Comparer) output(msg string, args ...interface{}) {
-	fmt.Fprintf(c.out, msg, args...) //nolint:errcheck
+func (c *Comparer) output(statsOnly bool, msg string, args ...interface{}) {
+	if !statsOnly {
+		fmt.Fprintf(c.out, msg, args...) //nolint:errcheck
+	}
 }
 
 // NewComparer creates a comparer for a given repository that will output the results to a given writer.
-func NewComparer(out io.Writer) (*Comparer, error) {
+func NewComparer(out io.Writer, statsOnly bool) (*Comparer, error) {
 	tmp, err := os.MkdirTemp("", "kopia")
 	if err != nil {
 		return nil, errors.Wrap(err, "error creating temp directory")
 	}
 
-	return &Comparer{out: out, tmpDir: tmp}, nil
+	return &Comparer{out: out, tmpDir: tmp, statsOnly: statsOnly}, nil
+}
+
+// GetPrecedingSnapshot fetches the snapshot manifest for the snapshot immediately preceding the given snapshotID if it exists.
+func GetPrecedingSnapshot(ctx context.Context, rep repo.Repository, snapshotID string) (*snapshot.Manifest, error) {
+	snapshotManifest, err := snapshotfs.FindSnapshotByRootObjectIDOrManifestID(ctx, rep, snapshotID, true)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get snapshot manifest for the given snapshotID")
+	}
+
+	snapshotList, err := snapshot.ListSnapshots(ctx, rep, snapshotManifest.Source)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to list snapshots")
+	}
+
+	// sort snapshots in ascending order based on start time
+	sort.Slice(snapshotList, func(i, j int) bool {
+		return snapshotList[i].StartTime.Before(snapshotList[j].StartTime)
+	})
+
+	for i, snap := range snapshotList {
+		if snap.ID == snapshotManifest.ID {
+			if i == 0 {
+				return nil, errors.New("there is no immediately preceding snapshot")
+			}
+
+			return snapshotList[i-1], nil
+		}
+	}
+
+	return nil, errors.New("couldn't find immediately preceding snapshot")
+}
+
+// GetTwoLatestSnapshotsForASource fetches two latest snapshot manifests for a given source if they exist.
+func GetTwoLatestSnapshotsForASource(ctx context.Context, rep repo.Repository, source snapshot.SourceInfo) (secondLast, last *snapshot.Manifest, err error) {
+	snapshots, err := snapshot.ListSnapshots(ctx, rep, source)
+	if err != nil {
+		return nil, nil, errors.Wrap(err, "failed to list all snapshots")
+	}
+
+	const minimumReqSnapshots = 2
+
+	sizeSnapshots := len(snapshots)
+	if sizeSnapshots < minimumReqSnapshots {
+		return nil, nil, errors.New("snapshot source has less than two snapshots")
+	}
+
+	sort.Slice(snapshots, func(i, j int) bool {
+		return snapshots[i].StartTime.Before(snapshots[j].StartTime)
+	})
+
+	return snapshots[sizeSnapshots-2], snapshots[sizeSnapshots-1], nil
 }
diff --git a/snapshot/snapshotfs/objref.go b/snapshot/snapshotfs/objref.go
index 3a84c092..c2c477e6 100644
--- a/snapshot/snapshotfs/objref.go
+++ b/snapshot/snapshotfs/objref.go
@@ -75,12 +75,12 @@ func parseNestedObjectID(ctx context.Context, startingDir fs.Entry, parts []stri
 	return hoid.ObjectID(), nil
 }
 
-// findSnapshotByRootObjectIDOrManifestID returns the list of matching snapshots for a given rootID.
+// FindSnapshotByRootObjectIDOrManifestID returns the list of matching snapshots for a given rootID.
 // which can be either snapshot manifst ID (which matches 0 or 1 snapshots)
 // or the root object ID (which can match arbitrary number of snapshots).
 // If multiple snapshots match and they don't agree on root object attributes and consistentAttributes==true
 // the function fails, otherwise it returns the latest of the snapshots.
-func findSnapshotByRootObjectIDOrManifestID(ctx context.Context, rep repo.Repository, rootID string, consistentAttributes bool) (*snapshot.Manifest, error) {
+func FindSnapshotByRootObjectIDOrManifestID(ctx context.Context, rep repo.Repository, rootID string, consistentAttributes bool) (*snapshot.Manifest, error) {
 	m, err := snapshot.LoadSnapshot(ctx, rep, manifest.ID(rootID))
 	if err == nil {
 		return m, nil
@@ -154,7 +154,7 @@ func FilesystemEntryFromIDWithPath(ctx context.Context, rep repo.Repository, roo
 
 	var startingEntry fs.Entry
 
-	man, err := findSnapshotByRootObjectIDOrManifestID(ctx, rep, pathElements[0], consistentAttributes)
+	man, err := FindSnapshotByRootObjectIDOrManifestID(ctx, rep, pathElements[0], consistentAttributes)
 	if err != nil {
 		return nil, err
 	}


Test patch:
diff --git a/internal/diff/diff_test.go b/internal/diff/diff_test.go
index 30deb20e..5851a49a 100644
--- a/internal/diff/diff_test.go
+++ b/internal/diff/diff_test.go
@@ -13,10 +13,17 @@
 
 	"github.com/kopia/kopia/fs"
 	"github.com/kopia/kopia/internal/diff"
+	"github.com/kopia/kopia/internal/repotesting"
+	"github.com/kopia/kopia/internal/testlogging"
+	"github.com/kopia/kopia/repo"
 	"github.com/kopia/kopia/repo/content/index"
+	"github.com/kopia/kopia/repo/manifest"
 	"github.com/kopia/kopia/repo/object"
+	"github.com/kopia/kopia/snapshot"
 )
 
+const statsOnly = false
+
 var (
 	_ fs.Entry     = (*testFile)(nil)
 	_ fs.Directory = (*testDirectory)(nil)
@@ -110,7 +117,7 @@ func TestCompareEmptyDirectories(t *testing.T) {
 	dir1 := createTestDirectory("testDir1", dirModTime, dirOwnerInfo, dirMode, dirObjectID1)
 	dir2 := createTestDirectory("testDir2", dirModTime, dirOwnerInfo, dirMode, dirObjectID2)
 
-	c, err := diff.NewComparer(&buf)
+	c, err := diff.NewComparer(&buf, statsOnly)
 	require.NoError(t, err)
 
 	t.Cleanup(func() {
@@ -165,7 +172,7 @@ func TestCompareIdenticalDirectories(t *testing.T) {
 
 	expectedStats := diff.Stats{}
 
-	c, err := diff.NewComparer(&buf)
+	c, err := diff.NewComparer(&buf, statsOnly)
 	require.NoError(t, err)
 
 	t.Cleanup(func() {
@@ -214,7 +221,7 @@ func TestCompareDifferentDirectories(t *testing.T) {
 		&testFile{testBaseEntry: testBaseEntry{modtime: fileModTime, name: "file4.txt"}, content: "klmnopqrstuvwxyz2"},
 	)
 
-	c, err := diff.NewComparer(&buf)
+	c, err := diff.NewComparer(&buf, statsOnly)
 	require.NoError(t, err)
 
 	t.Cleanup(func() {
@@ -275,7 +282,7 @@ func TestCompareDifferentDirectories_DirTimeDiff(t *testing.T) {
 	expectedStats := diff.Stats{}
 	expectedStats.DirectoryEntries.Modified = 1
 
-	c, err := diff.NewComparer(&buf)
+	c, err := diff.NewComparer(&buf, statsOnly)
 	require.NoError(t, err)
 
 	t.Cleanup(func() {
@@ -324,7 +331,7 @@ func TestCompareDifferentDirectories_FileTimeDiff(t *testing.T) {
 		&testFile{testBaseEntry: testBaseEntry{modtime: fileModTime2, name: "file1.txt", oid: OID2}, content: "abcdefghij"},
 	)
 
-	c, err := diff.NewComparer(&buf)
+	c, err := diff.NewComparer(&buf, statsOnly)
 	require.NoError(t, err)
 
 	t.Cleanup(func() {
@@ -382,7 +389,7 @@ func TestCompareFileWithIdenticalContentsButDiffFileMetadata(t *testing.T) {
 		&testFile{testBaseEntry: testBaseEntry{name: "file1.txt", modtime: fileModTime2, oid: object.ID{}, owner: fileOwnerinfo2, mode: 0o777}, content: "abcdefghij"},
 	)
 
-	c, err := diff.NewComparer(&buf)
+	c, err := diff.NewComparer(&buf, statsOnly)
 	require.NoError(t, err)
 
 	t.Cleanup(func() {
@@ -442,7 +449,7 @@ func TestCompareIdenticalDirectoriesWithDiffDirectoryMetadata(t *testing.T) {
 		dirObjectID,
 		&testFile{testBaseEntry: testBaseEntry{name: "file1.txt", modtime: fileModTime}, content: "abcdefghij"},
 	)
-	c, err := diff.NewComparer(&buf)
+	c, err := diff.NewComparer(&buf, statsOnly)
 	require.NoError(t, err)
 
 	t.Cleanup(func() {
@@ -469,3 +476,144 @@ func TestCompareIdenticalDirectoriesWithDiffDirectoryMetadata(t *testing.T) {
 func createTestDirectory(name string, modtime time.Time, owner fs.OwnerInfo, mode os.FileMode, oid object.ID, files ...fs.Entry) *testDirectory {
 	return &testDirectory{testBaseEntry: testBaseEntry{modtime: modtime, name: name, owner: owner, mode: mode, oid: oid}, files: files}
 }
+
+func getManifests() map[string]*snapshot.Manifest {
+	// manifests store snapshot manifests based on start-time
+	manifests := make(map[string]*snapshot.Manifest)
+
+	src := getSnapshotSource()
+	snapshotTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
+
+	contentID1, _ := index.IDFromHash("p", []byte("indexID1"))
+	objectID1 := object.DirectObjectID(contentID1)
+
+	contentID2, _ := index.IDFromHash("p", []byte("indexID2"))
+	objectID2 := object.DirectObjectID(contentID2)
+
+	rootEntry1 := snapshot.DirEntry{
+		ObjectID: objectID1,
+	}
+
+	rootEntry2 := snapshot.DirEntry{
+		ObjectID: objectID2,
+	}
+
+	initialSnapshotManifest := &snapshot.Manifest{
+		ID:          "manifest_1_id",
+		Source:      src,
+		StartTime:   fs.UTCTimestamp(snapshotTime.Add((-24) * time.Hour).UnixNano()),
+		Description: "snapshot captured a day ago",
+		RootEntry:   &rootEntry2,
+	}
+	manifests["initial_snapshot"] = initialSnapshotManifest
+
+	intermediateSnapshotManifest := &snapshot.Manifest{
+		ID:          "manifest_2_id",
+		Source:      src,
+		StartTime:   fs.UTCTimestamp(snapshotTime.Add(-time.Hour).UnixNano()),
+		Description: "snapshot taken an hour ago",
+		RootEntry:   &rootEntry2,
+	}
+	manifests["intermediate_snapshot"] = intermediateSnapshotManifest
+
+	LatestSnapshotManifest := &snapshot.Manifest{
+		ID:          "manifest_3_id",
+		Source:      src,
+		StartTime:   fs.UTCTimestamp(snapshotTime.UnixNano()),
+		Description: "latest snapshot",
+		RootEntry:   &rootEntry1,
+	}
+	manifests["latest_snapshot"] = LatestSnapshotManifest
+
+	return manifests
+}
+
+// Tests GetPrecedingSnapshot function
+//   - GetPrecedingSnapshot with an invalid snapshot id and expect an error;
+//   - Add a snapshot, expect an error from GetPreceedingSnapshot since there is
+//     only a single snapshot in the repo;
+//   - Subsequently add more snapshots and GetPreceedingSnapshot theimmediately
+//     preceding with no error.
+func TestGetPrecedingSnapshot(t *testing.T) {
+	ctx, env := repotesting.NewEnvironment(t, repotesting.FormatNotImportant)
+	manifests := getManifests()
+
+	_, err := diff.GetPrecedingSnapshot(ctx, env.RepositoryWriter, "non_existent_snapshot_ID")
+	require.Error(t, err, "expect error when calling GetPrecedingSnapshot with a wrong snapshotID")
+
+	initialSnapshotManifestID := mustSaveSnapshot(t, env.RepositoryWriter, manifests["initial_snapshot"])
+	_, err = diff.GetPrecedingSnapshot(ctx, env.RepositoryWriter, string(initialSnapshotManifestID))
+	require.Error(t, err, "expect error when there is a single snapshot in the repo")
+
+	intermediateSnapshotManifestID := mustSaveSnapshot(t, env.RepositoryWriter, manifests["intermediate_snapshot"])
+	gotManID, err := diff.GetPrecedingSnapshot(ctx, env.RepositoryWriter, string(intermediateSnapshotManifestID))
+	require.NoError(t, err)
+	require.Equal(t, initialSnapshotManifestID, gotManID.ID)
+
+	latestSnapshotManifestID := mustSaveSnapshot(t, env.RepositoryWriter, manifests["latest_snapshot"])
+	gotManID2, err := diff.GetPrecedingSnapshot(ctx, env.RepositoryWriter, string(latestSnapshotManifestID))
+	require.NoError(t, err)
+	require.Equal(t, intermediateSnapshotManifestID, gotManID2.ID)
+}
+
+// First call GetTwoLatestSnapshots with insufficient snapshots in the repo and
+// expect an error;
+// As snapshots are added, GetTwoLatestSnapshots is expected to return the
+// manifests for the two most recent snapshots for a the given source.
+func TestGetTwoLatestSnapshots(t *testing.T) {
+	ctx, env := repotesting.NewEnvironment(t, repotesting.FormatNotImportant)
+
+	snapshotSrc := getSnapshotSource()
+	manifests := getManifests()
+
+	_, _, err := diff.GetTwoLatestSnapshotsForASource(ctx, env.RepositoryWriter, snapshotSrc)
+	require.Error(t, err, "expected error as there aren't enough snapshots to get the two most recent snapshots")
+
+	initialSnapshotManifestID := mustSaveSnapshot(t, env.RepositoryWriter, manifests["initial_snapshot"])
+	_, _, err = diff.GetTwoLatestSnapshotsForASource(ctx, env.RepositoryWriter, snapshotSrc)
+	require.Error(t, err, "expected error as there aren't enough snapshots to get the two most recent snapshots")
+
+	intermediateSnapshotManifestID := mustSaveSnapshot(t, env.RepositoryWriter, manifests["intermediate_snapshot"])
+
+	var expectedManifestIDs []manifest.ID
+	expectedManifestIDs = append(expectedManifestIDs, initialSnapshotManifestID, intermediateSnapshotManifestID)
+
+	secondLastSnapshot, lastSnapshot, err := diff.GetTwoLatestSnapshotsForASource(ctx, env.RepositoryWriter, snapshotSrc)
+
+	var gotManifestIDs []manifest.ID
+	gotManifestIDs = append(gotManifestIDs, secondLastSnapshot.ID, lastSnapshot.ID)
+
+	require.NoError(t, err)
+	require.Equal(t, expectedManifestIDs, gotManifestIDs)
+
+	latestSnapshotManifestID := mustSaveSnapshot(t, env.RepositoryWriter, manifests["latest_snapshot"])
+
+	expectedManifestIDs = nil
+	expectedManifestIDs = append(expectedManifestIDs, intermediateSnapshotManifestID, latestSnapshotManifestID)
+
+	gotManifestIDs = nil
+	secondLastSnapshot, lastSnapshot, err = diff.GetTwoLatestSnapshotsForASource(ctx, env.RepositoryWriter, snapshotSrc)
+	gotManifestIDs = append(gotManifestIDs, secondLastSnapshot.ID, lastSnapshot.ID)
+
+	require.NoError(t, err)
+	require.Equal(t, expectedManifestIDs, gotManifestIDs)
+}
+
+func mustSaveSnapshot(t *testing.T, rep repo.RepositoryWriter, man *snapshot.Manifest) manifest.ID {
+	t.Helper()
+
+	id, err := snapshot.SaveSnapshot(testlogging.Context(t), rep, man)
+	require.NoError(t, err, "saving snapshot")
+
+	return id
+}
+
+func getSnapshotSource() snapshot.SourceInfo {
+	src := snapshot.SourceInfo{
+		Host:     "host-1",
+		UserName: "user-1",
+		Path:     "/some/path",
+	}
+
+	return src
+}
diff --git a/tests/end_to_end_test/restore_test.go b/tests/end_to_end_test/restore_test.go
index c4828b37..e2d5843f 100644
--- a/tests/end_to_end_test/restore_test.go
+++ b/tests/end_to_end_test/restore_test.go
@@ -177,6 +177,8 @@ func TestRestoreCommand(t *testing.T) {
 func compareDirs(t *testing.T, source, restoreDir string) {
 	t.Helper()
 
+	const statsOnly = false
+
 	// Restored contents should match source
 	s, err := localfs.Directory(source)
 	require.NoError(t, err)
@@ -192,7 +194,7 @@ func compareDirs(t *testing.T, source, restoreDir string) {
 	require.NoError(t, err)
 
 	if !assert.Equal(t, wantHash, gotHash, "restored directory hash does not match source's hash") {
-		cmp, err := diff.NewComparer(os.Stderr)
+		cmp, err := diff.NewComparer(os.Stderr, statsOnly)
 		require.NoError(t, err)
 
 		cmp.DiffCommand = "cmp"
diff --git a/tests/htmlui_e2e_test/htmlui_e2e_test.go b/tests/htmlui_e2e_test/htmlui_e2e_test.go
index 62c2e017..8c4050b7 100644
--- a/tests/htmlui_e2e_test/htmlui_e2e_test.go
+++ b/tests/htmlui_e2e_test/htmlui_e2e_test.go
@@ -356,7 +356,7 @@ func TestByteRepresentation(t *testing.T) {
 			tc.log("clicking on snapshots tab"),
 			chromedp.Click("a[data-testid='tab-snapshots']", chromedp.BySearch),
 			// getting text from the third column of the first row indicating the size of the snapshot
-			chromedp.Text(`#root > div > table > tbody > tr:nth-child(1) > td:nth-child(3)`, &base2, chromedp.ByQuery),
+			chromedp.Text(`#root table > tbody > tr:nth-child(1) > td:nth-child(3)`, &base2, chromedp.ByQuery),
 			tc.captureScreenshot("snapshot-base-2"),
 
 			tc.log("clicking on preferences tab"),
@@ -369,7 +369,7 @@ func TestByteRepresentation(t *testing.T) {
 			tc.log("clicking on snapshots tab"),
 			chromedp.Click("a[data-testid='tab-snapshots']", chromedp.BySearch),
 			// getting text from the third column of the first row indicating the size of the snapshot
-			chromedp.Text(`#root > div > table > tbody > tr:nth-child(1) > td:nth-child(3)`, &base10, chromedp.BySearch),
+			chromedp.Text(`#root table > tbody > tr:nth-child(1) > td:nth-child(3)`, &base10, chromedp.BySearch),
 			tc.captureScreenshot("snapshot-base-10"),
 		))
 
diff --git a/tests/recovery/recovery_test/recovery_test.go b/tests/recovery/recovery_test/recovery_test.go
index d588669b..d0b5479b 100644
--- a/tests/recovery/recovery_test/recovery_test.go
+++ b/tests/recovery/recovery_test/recovery_test.go
@@ -297,7 +297,9 @@ func CompareDirs(t *testing.T, source, destination string) {
 
 	ctx := context.Background()
 
-	c, err := diff.NewComparer(&buf)
+	const statsOnly = false
+
+	c, err := diff.NewComparer(&buf, statsOnly)
 	require.NoError(t, err)
 
 	t.Cleanup(func() {