Repository: restic/restic (go)

Issue:
Output new snapshot ID whenever a command causes an existing ID to change
Output of `restic version`
--------------------------

restic 0.17.1 compiled with go1.23.1 on linux/amd64

What should restic do differently? Which functionality do you think we should add?
----------------------------------------------------------------------------------

For example when adding tags to a snapshot, it would be helpful if the output returned the resulting new snapshot ID.

What are you trying to do? What problem would this solve?
---------------------------------------------------------

Finding the same snapshot again after its ID has changed. Having to list snapshots again to find the new ID, and potentially having to compare using other metadata fields to try to ensure that it's the same one.

Sometimes one might make a note of a specific ID because the snapshot was created for a specific purpose (eg: before an upgrade), or because it was determined to be the one needed out of a set of older ones. (Yes, one could also use tags to mark these, but that's not really the point.)

Perhaps it would also be possible to (optionally) list all previous IDs, although I think only the most recent previous one is recorded.

Did restic help you today? Did it make you happy in any way?
------------------------------------------------------------

It makes me happy every time it Just Works™, which is almost always.

Pull request description:
tag: output the original ID and new snapshotID
What does this PR change? What problem does it solve?
-----------------------------------------------------
This PR adds the ability of the restic tag command to return the modified snapshot ID when the tag command is run.

Was the change previously discussed in an issue or on the forum?
----------------------------------------------------------------
Closes #5137 

Checklist
---------
- [ ] I have added tests for all code changes.
- [x] I have added documentation for relevant changes (in the manual).
- [x] There's a new file in `changelog/unreleased/` that describes the changes for our users (see [template](https://github.com/restic/restic/blob/master/changelog/TEMPLATE)).
- [x] I'm done! This pull request is ready for review.


Patch:
diff --git a/changelog/unreleased/issue-5137 b/changelog/unreleased/issue-5137
new file mode 100644
index 000000000..ba681202c
--- /dev/null
+++ b/changelog/unreleased/issue-5137
@@ -0,0 +1,8 @@
+Enhancement: Restic tag command returns the modified snapshot information
+
+Restic `tag` command now returns the modified snapshot information in the
+output. Added `--json` option to the command to get the output in JSON format
+for scripting access.
+
+https://github.com/restic/restic/issues/5137
+https://github.com/restic/restic/pull/5144
\ No newline at end of file
diff --git a/cmd/restic/cmd_tag.go b/cmd/restic/cmd_tag.go
index 539a0cc59..f71e2556c 100644
--- a/cmd/restic/cmd_tag.go
+++ b/cmd/restic/cmd_tag.go
@@ -9,6 +9,8 @@ import (
 	"github.com/restic/restic/internal/errors"
 	"github.com/restic/restic/internal/repository"
 	"github.com/restic/restic/internal/restic"
+	"github.com/restic/restic/internal/ui"
+	"github.com/restic/restic/internal/ui/termstatus"
 )
 
 var cmdTag = &cobra.Command{
@@ -34,7 +36,9 @@ Exit status is 12 if the password is incorrect.
 	GroupID:           cmdGroupDefault,
 	DisableAutoGenTag: true,
 	RunE: func(cmd *cobra.Command, args []string) error {
-		return runTag(cmd.Context(), tagOptions, globalOptions, args)
+		term, cancel := setupTermstatus()
+		defer cancel()
+		return runTag(cmd.Context(), tagOptions, globalOptions, term, args)
 	},
 }
 
@@ -58,7 +62,18 @@ func init() {
 	initMultiSnapshotFilter(tagFlags, &tagOptions.SnapshotFilter, true)
 }
 
-func changeTags(ctx context.Context, repo *repository.Repository, sn *restic.Snapshot, setTags, addTags, removeTags []string) (bool, error) {
+type changedSnapshot struct {
+	MessageType   string    `json:"message_type"` // changed
+	OldSnapshotID restic.ID `json:"old_snapshot_id"`
+	NewSnapshotID restic.ID `json:"new_snapshot_id"`
+}
+
+type changedSnapshotsSummary struct {
+	MessageType      string `json:"message_type"` // summary
+	ChangedSnapshots int    `json:"changed_snapshots"`
+}
+
+func changeTags(ctx context.Context, repo *repository.Repository, sn *restic.Snapshot, setTags, addTags, removeTags []string, printFunc func(changedSnapshot)) (bool, error) {
 	var changed bool
 
 	if len(setTags) != 0 {
@@ -87,7 +102,7 @@ func changeTags(ctx context.Context, repo *repository.Repository, sn *restic.Sna
 			return false, err
 		}
 
-		debug.Log("new snapshot saved as %v", id)
+		debug.Log("old snapshot %v saved as a new snapshot %v", sn.ID(), id)
 
 		// Remove the old snapshot.
 		if err = repo.RemoveUnpacked(ctx, restic.WriteableSnapshotFile, *sn.ID()); err != nil {
@@ -95,11 +110,13 @@ func changeTags(ctx context.Context, repo *repository.Repository, sn *restic.Sna
 		}
 
 		debug.Log("old snapshot %v removed", sn.ID())
+
+		printFunc(changedSnapshot{MessageType: "changed", OldSnapshotID: *sn.ID(), NewSnapshotID: id})
 	}
 	return changed, nil
 }
 
-func runTag(ctx context.Context, opts TagOptions, gopts GlobalOptions, args []string) error {
+func runTag(ctx context.Context, opts TagOptions, gopts GlobalOptions, term *termstatus.Terminal, args []string) error {
 	if len(opts.SetTags) == 0 && len(opts.AddTags) == 0 && len(opts.RemoveTags) == 0 {
 		return errors.Fatal("nothing to do!")
 	}
@@ -114,24 +131,44 @@ func runTag(ctx context.Context, opts TagOptions, gopts GlobalOptions, args []st
 	}
 	defer unlock()
 
-	changeCnt := 0
+	printFunc := func(c changedSnapshot) {
+		Verboseff("old snapshot ID: %v -> new snapshot ID: %v\n", c.OldSnapshotID, c.NewSnapshotID)
+	}
+
+	summary := changedSnapshotsSummary{MessageType: "summary", ChangedSnapshots: 0}
+	printSummary := func(c changedSnapshotsSummary) {
+		if c.ChangedSnapshots == 0 {
+			Verbosef("no snapshots were modified\n")
+		} else {
+			Verbosef("modified %v snapshots\n", c.ChangedSnapshots)
+		}
+	}
+
+	if gopts.JSON {
+		printFunc = func(c changedSnapshot) {
+			term.Print(ui.ToJSONString(c))
+		}
+		printSummary = func(c changedSnapshotsSummary) {
+			term.Print(ui.ToJSONString(c))
+		}
+	}
+
 	for sn := range FindFilteredSnapshots(ctx, repo, repo, &opts.SnapshotFilter, args) {
-		changed, err := changeTags(ctx, repo, sn, opts.SetTags.Flatten(), opts.AddTags.Flatten(), opts.RemoveTags.Flatten())
+		changed, err := changeTags(ctx, repo, sn, opts.SetTags.Flatten(), opts.AddTags.Flatten(), opts.RemoveTags.Flatten(), printFunc)
 		if err != nil {
 			Warnf("unable to modify the tags for snapshot ID %q, ignoring: %v\n", sn.ID(), err)
 			continue
 		}
 		if changed {
-			changeCnt++
+			summary.ChangedSnapshots++
 		}
 	}
+
 	if ctx.Err() != nil {
 		return ctx.Err()
 	}
-	if changeCnt == 0 {
-		Verbosef("no snapshots were modified\n")
-	} else {
-		Verbosef("modified tags on %v snapshots\n", changeCnt)
-	}
+
+	printSummary(summary)
+
 	return nil
 }
diff --git a/doc/075_scripting.rst b/doc/075_scripting.rst
index 39a6dbc7f..57a8e2872 100644
--- a/doc/075_scripting.rst
+++ b/doc/075_scripting.rst
@@ -722,6 +722,30 @@ The stats command returns a single JSON object.
 | ``compression_space_saving`` | Overall space saving due to compression             |
 +------------------------------+-----------------------------------------------------+
 
+tag
+---
+
+The ``tag`` command uses the JSON lines format with the following message types.
+
+Changed
+^^^^^^^
+
++--------------------------+-------------------------------------------+
+| ``message_type``         | Always "changed"                          |
++--------------------------+-------------------------------------------+
+| ``old_snapshot_id``      | ID of the snapshot before the change      |
++--------------------------+-------------------------------------------+
+| ``new_snapshot_id``      | ID of the snapshot after the change       |
++--------------------------+-------------------------------------------+
+
+Summary
+^^^^^^^
+
++-----------------------------+-------------------------------------------+
+| ``message_type``            | Always "summary"                          |
++-----------------------------+-------------------------------------------+
+| ``changed_snapshot_count``  | Total number of changed snapshots         |
++-----------------------------+-------------------------------------------+
 
 version
 -------


Test patch:
diff --git a/cmd/restic/cmd_tag_integration_test.go b/cmd/restic/cmd_tag_integration_test.go
index 6979f9c11..53360ca84 100644
--- a/cmd/restic/cmd_tag_integration_test.go
+++ b/cmd/restic/cmd_tag_integration_test.go
@@ -9,7 +9,7 @@ import (
 )
 
 func testRunTag(t testing.TB, opts TagOptions, gopts GlobalOptions) {
-	rtest.OK(t, runTag(context.TODO(), opts, gopts, []string{}))
+	rtest.OK(t, runTag(context.TODO(), opts, gopts, nil, []string{}))
 }
 
 // nolint: staticcheck // false positive nil pointer dereference check