commit
a25d280f3e
4 changed files with 374 additions and 3 deletions
|
@ -11,6 +11,12 @@ Important Changes in 0.X.Y
|
||||||
https://github.com/restic/restic/issues/1457
|
https://github.com/restic/restic/issues/1457
|
||||||
https://github.com/restic/restic/issues/1466
|
https://github.com/restic/restic/issues/1466
|
||||||
|
|
||||||
|
* The command `diff` was added, it allows comparing two snapshots and listing
|
||||||
|
all differences.
|
||||||
|
https://github.com/restic/restic/issues/11
|
||||||
|
https://github.com/restic/restic/issues/1460
|
||||||
|
https://github.com/restic/restic/pull/1462
|
||||||
|
|
||||||
|
|
||||||
Small changes
|
Small changes
|
||||||
-------------
|
-------------
|
||||||
|
|
340
cmd/restic/cmd_diff.go
Normal file
340
cmd/restic/cmd_diff.go
Normal file
|
@ -0,0 +1,340 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/restic/restic/internal/debug"
|
||||||
|
"github.com/restic/restic/internal/errors"
|
||||||
|
"github.com/restic/restic/internal/repository"
|
||||||
|
"github.com/restic/restic/internal/restic"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var cmdDiff = &cobra.Command{
|
||||||
|
Use: "diff snapshot-ID snapshot-ID",
|
||||||
|
Short: "Show differences between two snapshots",
|
||||||
|
Long: `
|
||||||
|
The "diff" command shows differences from the first to the second snapshot. The
|
||||||
|
first characters in each line display what has happened to a particular file or
|
||||||
|
directory:
|
||||||
|
|
||||||
|
+ The item was added
|
||||||
|
- The item was removed
|
||||||
|
M The metadata (access mode, timestamps, ...) for the item was changed
|
||||||
|
C The contents of a file has changed
|
||||||
|
T The type was changed, e.g. a file was made a symlink
|
||||||
|
`,
|
||||||
|
DisableAutoGenTag: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runDiff(diffOptions, globalOptions, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiffOptions collects all options for the diff command.
|
||||||
|
type DiffOptions struct {
|
||||||
|
ShowMetadata bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var diffOptions DiffOptions
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
cmdRoot.AddCommand(cmdDiff)
|
||||||
|
|
||||||
|
f := cmdDiff.Flags()
|
||||||
|
f.BoolVar(&diffOptions.ShowMetadata, "metadata", false, "print changes in metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSnapshot(ctx context.Context, repo *repository.Repository, desc string) (*restic.Snapshot, error) {
|
||||||
|
id, err := restic.FindSnapshot(repo, desc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return restic.LoadSnapshot(ctx, repo, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comparer collects all things needed to compare two snapshots.
|
||||||
|
type Comparer struct {
|
||||||
|
repo restic.Repository
|
||||||
|
opts DiffOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiffStats collects the differences between two snapshots.
|
||||||
|
type DiffStats struct {
|
||||||
|
FilesAdded, FilesRemoved, FilesChanged int
|
||||||
|
DirsAdded, DirsRemoved int
|
||||||
|
OthersAdded, OthersRemoved int
|
||||||
|
DataBlobsAdded, DataBlobsRemoved int
|
||||||
|
TreeBlobsAdded, TreeBlobsRemoved int
|
||||||
|
BytesAdded, BytesRemoved int
|
||||||
|
|
||||||
|
blobsBefore, blobsAfter restic.BlobSet
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDiffStats creates new stats for a diff run.
|
||||||
|
func NewDiffStats() *DiffStats {
|
||||||
|
return &DiffStats{
|
||||||
|
blobsBefore: restic.NewBlobSet(),
|
||||||
|
blobsAfter: restic.NewBlobSet(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddNodeBefore records all blobs of node to the stats of the first snapshot.
|
||||||
|
func (stats *DiffStats) AddNodeBefore(node *restic.Node) {
|
||||||
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch node.Type {
|
||||||
|
case "file":
|
||||||
|
for _, blob := range node.Content {
|
||||||
|
h := restic.BlobHandle{
|
||||||
|
ID: blob,
|
||||||
|
Type: restic.DataBlob,
|
||||||
|
}
|
||||||
|
stats.blobsBefore.Insert(h)
|
||||||
|
}
|
||||||
|
case "dir":
|
||||||
|
h := restic.BlobHandle{
|
||||||
|
ID: *node.Subtree,
|
||||||
|
Type: restic.TreeBlob,
|
||||||
|
}
|
||||||
|
stats.blobsBefore.Insert(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddNodeAfter records all blobs of node to the stats of the second snapshot.
|
||||||
|
func (stats *DiffStats) AddNodeAfter(node *restic.Node) {
|
||||||
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch node.Type {
|
||||||
|
case "file":
|
||||||
|
for _, blob := range node.Content {
|
||||||
|
h := restic.BlobHandle{
|
||||||
|
ID: blob,
|
||||||
|
Type: restic.DataBlob,
|
||||||
|
}
|
||||||
|
stats.blobsAfter.Insert(h)
|
||||||
|
}
|
||||||
|
case "dir":
|
||||||
|
h := restic.BlobHandle{
|
||||||
|
ID: *node.Subtree,
|
||||||
|
Type: restic.TreeBlob,
|
||||||
|
}
|
||||||
|
stats.blobsAfter.Insert(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBlobs updates the blob counters in the stats struct.
|
||||||
|
func (stats *DiffStats) UpdateBlobs(repo restic.Repository) {
|
||||||
|
both := stats.blobsBefore.Intersect(stats.blobsAfter)
|
||||||
|
for h := range stats.blobsBefore.Sub(both) {
|
||||||
|
switch h.Type {
|
||||||
|
case restic.DataBlob:
|
||||||
|
stats.DataBlobsRemoved++
|
||||||
|
case restic.TreeBlob:
|
||||||
|
stats.TreeBlobsRemoved++
|
||||||
|
}
|
||||||
|
|
||||||
|
size, err := repo.LookupBlobSize(h.ID, h.Type)
|
||||||
|
if err != nil {
|
||||||
|
Warnf("unable to find blob size for %v: %v\n", h, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.BytesRemoved += int(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
for h := range stats.blobsAfter.Sub(both) {
|
||||||
|
switch h.Type {
|
||||||
|
case restic.DataBlob:
|
||||||
|
stats.DataBlobsAdded++
|
||||||
|
case restic.TreeBlob:
|
||||||
|
stats.TreeBlobsAdded++
|
||||||
|
}
|
||||||
|
|
||||||
|
size, err := repo.LookupBlobSize(h.ID, h.Type)
|
||||||
|
if err != nil {
|
||||||
|
Warnf("unable to find blob size for %v: %v\n", h, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.BytesAdded += int(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Comparer) diffTree(ctx context.Context, stats *DiffStats, prefix string, id1, id2 restic.ID) error {
|
||||||
|
debug.Log("diffing %v to %v", id1, id2)
|
||||||
|
tree1, err := c.repo.LoadTree(ctx, id1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tree2, err := c.repo.LoadTree(ctx, id2)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
uniqueNames := make(map[string]struct{})
|
||||||
|
tree1Nodes := make(map[string]*restic.Node)
|
||||||
|
for _, node := range tree1.Nodes {
|
||||||
|
tree1Nodes[node.Name] = node
|
||||||
|
uniqueNames[node.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
tree2Nodes := make(map[string]*restic.Node)
|
||||||
|
for _, node := range tree2.Nodes {
|
||||||
|
tree2Nodes[node.Name] = node
|
||||||
|
uniqueNames[node.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, 0, len(uniqueNames))
|
||||||
|
for name := range uniqueNames {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Sort(sort.StringSlice(names))
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
node1, t1 := tree1Nodes[name]
|
||||||
|
node2, t2 := tree2Nodes[name]
|
||||||
|
|
||||||
|
stats.AddNodeBefore(node1)
|
||||||
|
stats.AddNodeAfter(node2)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case t1 && t2:
|
||||||
|
name := path.Join(prefix, name)
|
||||||
|
mod := ""
|
||||||
|
|
||||||
|
if node1.Type != node2.Type {
|
||||||
|
mod += "T"
|
||||||
|
}
|
||||||
|
|
||||||
|
if node2.Type == "dir" {
|
||||||
|
name += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
if node1.Type == "file" &&
|
||||||
|
node2.Type == "file" &&
|
||||||
|
!reflect.DeepEqual(node1.Content, node2.Content) {
|
||||||
|
mod += "C"
|
||||||
|
stats.FilesChanged++
|
||||||
|
|
||||||
|
if c.opts.ShowMetadata && !node1.Equals(*node2) {
|
||||||
|
mod += "M"
|
||||||
|
}
|
||||||
|
} else if c.opts.ShowMetadata && !node1.Equals(*node2) {
|
||||||
|
mod += "M"
|
||||||
|
}
|
||||||
|
|
||||||
|
if mod != "" {
|
||||||
|
Printf(" % -3v %v\n", mod, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if node1.Type == "dir" && node2.Type == "dir" {
|
||||||
|
err := c.diffTree(ctx, stats, name, *node1.Subtree, *node2.Subtree)
|
||||||
|
if err != nil {
|
||||||
|
Warnf("error: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case t1 && !t2:
|
||||||
|
Printf("- %v\n", path.Join(prefix, name))
|
||||||
|
switch node1.Type {
|
||||||
|
case "file":
|
||||||
|
stats.FilesRemoved++
|
||||||
|
case "dir":
|
||||||
|
stats.DirsRemoved++
|
||||||
|
default:
|
||||||
|
stats.OthersRemoved++
|
||||||
|
}
|
||||||
|
case !t1 && t2:
|
||||||
|
Printf("+ %v\n", path.Join(prefix, name))
|
||||||
|
switch node2.Type {
|
||||||
|
case "file":
|
||||||
|
stats.FilesAdded++
|
||||||
|
case "dir":
|
||||||
|
stats.DirsAdded++
|
||||||
|
default:
|
||||||
|
stats.OthersAdded++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDiff(opts DiffOptions, gopts GlobalOptions, args []string) error {
|
||||||
|
if len(args) != 2 {
|
||||||
|
return errors.Fatalf("specify two snapshot IDs")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(gopts.ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
repo, err := OpenRepository(gopts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = repo.LoadIndex(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !gopts.NoLock {
|
||||||
|
lock, err := lockRepo(repo)
|
||||||
|
defer unlockRepo(lock)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sn1, err := loadSnapshot(ctx, repo, args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sn2, err := loadSnapshot(ctx, repo, args[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
Verbosef("comparing snapshot %v to %v:\n\n", sn1.ID().Str(), sn2.ID().Str())
|
||||||
|
|
||||||
|
if sn1.Tree == nil {
|
||||||
|
return errors.Errorf("snapshot %v has nil tree", sn1.ID().Str())
|
||||||
|
}
|
||||||
|
|
||||||
|
if sn2.Tree == nil {
|
||||||
|
return errors.Errorf("snapshot %v has nil tree", sn2.ID().Str())
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &Comparer{
|
||||||
|
repo: repo,
|
||||||
|
opts: diffOptions,
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := NewDiffStats()
|
||||||
|
|
||||||
|
err = c.diffTree(ctx, stats, "/", *sn1.Tree, *sn2.Tree)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.UpdateBlobs(repo)
|
||||||
|
|
||||||
|
Printf("\n")
|
||||||
|
Printf("Files: %5d new, %5d removed, %5d changed\n", stats.FilesAdded, stats.FilesRemoved, stats.FilesChanged)
|
||||||
|
Printf("Dirs: %5d new, %5d removed\n", stats.DirsAdded, stats.DirsRemoved)
|
||||||
|
Printf("Others: %5d new, %5d removed\n", stats.OthersAdded, stats.OthersRemoved)
|
||||||
|
Printf("Data Blobs: %5d new, %5d removed\n", stats.DataBlobsAdded, stats.DataBlobsRemoved)
|
||||||
|
Printf("Tree Blobs: %5d new, %5d removed\n", stats.TreeBlobsAdded, stats.TreeBlobsRemoved)
|
||||||
|
Printf(" Added: %-5s\n", formatBytes(uint64(stats.BytesAdded)))
|
||||||
|
Printf(" Removed: %-5s\n", formatBytes(uint64(stats.BytesRemoved)))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -127,6 +127,31 @@ args:
|
||||||
|
|
||||||
$ restic -r /tmp/backup backup --files-from /tmp/files_to_backup /tmp/some_additional_file
|
$ restic -r /tmp/backup backup --files-from /tmp/files_to_backup /tmp/some_additional_file
|
||||||
|
|
||||||
|
Comparing Snapshots
|
||||||
|
*******************
|
||||||
|
|
||||||
|
Restic has a `diff` command which shows the difference between two snapshots
|
||||||
|
and displays a small statistic, just pass the command two snapshot IDs:
|
||||||
|
|
||||||
|
.. code-block:: console
|
||||||
|
|
||||||
|
$ restic -r /tmp/backup diff 5845b002 2ab627a6
|
||||||
|
password is correct
|
||||||
|
comparing snapshot ea657ce5 to 2ab627a6:
|
||||||
|
|
||||||
|
C /restic/cmd_diff.go
|
||||||
|
+ /restic/foo
|
||||||
|
C /restic/restic
|
||||||
|
|
||||||
|
Files: 0 new, 0 removed, 2 changed
|
||||||
|
Dirs: 1 new, 0 removed
|
||||||
|
Others: 0 new, 0 removed
|
||||||
|
Data Blobs: 14 new, 15 removed
|
||||||
|
Tree Blobs: 2 new, 1 removed
|
||||||
|
Added: 16.403 MiB
|
||||||
|
Removed: 16.402 MiB
|
||||||
|
|
||||||
|
|
||||||
Backing up special items and metadata
|
Backing up special items and metadata
|
||||||
*************************************
|
*************************************
|
||||||
|
|
||||||
|
|
|
@ -385,13 +385,13 @@ func (node Node) Equals(other Node) bool {
|
||||||
if node.Mode != other.Mode {
|
if node.Mode != other.Mode {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if node.ModTime != other.ModTime {
|
if !node.ModTime.Equal(other.ModTime) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if node.AccessTime != other.AccessTime {
|
if !node.AccessTime.Equal(other.AccessTime) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if node.ChangeTime != other.ChangeTime {
|
if !node.ChangeTime.Equal(other.ChangeTime) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if node.UID != other.UID {
|
if node.UID != other.UID {
|
||||||
|
|
Loading…
Reference in a new issue