cmd/txtar: support listing files

This is useful when wanting to round-trip a txtar file by extracting its
files into a new directory and then write the same archive again. We
can't simply read the directory to write the files to a txtar archive
again, as we'll likely just end up with sorted filenames.

That is not often what one wants; for example, for Go tests, it's often
best to keep go.mod at the top. It's also useful to keep an order that
makes sense for the human consuming the file in context.

Now, the roundtrip can be done correctly by listing the files and using
that list again when writing a txtar file back. Note that no variable
expansion happens when listing files, as otherwise it would be
impossible to keep the original variables in our round-trip scenario.

Change-Id: I384cca7ac4ce4dbfb0d3d0f437687b5a2f6298eb
Reviewed-on: https://go-review.googlesource.com/c/exp/+/437635
Reviewed-by: Ian Lance Taylor <iant@google.com>
Reviewed-by: Bryan Mills <bcmills@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Run-TryBot: Daniel Martí <mvdan@mvdan.cc>
diff --git a/cmd/txtar/txtar.go b/cmd/txtar/txtar.go
index 85219e5..4757a03 100644
--- a/cmd/txtar/txtar.go
+++ b/cmd/txtar/txtar.go
@@ -15,13 +15,17 @@
 // from stdin and extract all of its files to corresponding locations relative
 // to the current, writing the archive's comment to stdout.
 //
+// The --list flag instructs txtar to instead read the archive file from stdin
+// and list all of its files to stdout. Note that shell variables in paths are
+// not expanded in this mode.
+//
 // Archive files are by default extracted only to the current directory or its
 // subdirectories. To allow extracting outside the current directory, use the
 // --unsafe flag.
 //
-// Shell variables in paths are expanded (using os.Expand) if the corresponding
-// variable is set in the process environment. When writing an archive, the
-// variables (before expansion) are preserved in the archived paths.
+// When extracting, shell variables in paths are expanded (using os.Expand) if
+// the corresponding variable is set in the process environment. When writing an
+// archive, the variables (before expansion) are preserved in the archived paths.
 //
 // Example usage:
 //
@@ -47,6 +51,7 @@
 
 var (
 	extractFlag = flag.Bool("extract", false, "if true, extract files from the archive instead of writing to it")
+	listFlag    = flag.Bool("list", false, "if true, list files from the archive instead of writing to it")
 	unsafeFlag  = flag.Bool("unsafe", false, "allow extraction of files outside the current directory")
 )
 
@@ -58,13 +63,20 @@
 	flag.Parse()
 
 	var err error
-	if *extractFlag {
+	switch {
+	case *extractFlag:
 		if len(flag.Args()) > 0 {
 			fmt.Fprintln(os.Stderr, "Usage: txtar --extract <archive.txt")
 			os.Exit(2)
 		}
 		err = extract()
-	} else {
+	case *listFlag:
+		if len(flag.Args()) > 0 {
+			fmt.Fprintln(os.Stderr, "Usage: txtar --list <archive.txt")
+			os.Exit(2)
+		}
+		err = list()
+	default:
 		paths := flag.Args()
 		if len(paths) == 0 {
 			paths = []string{"."}
@@ -125,6 +137,19 @@
 	return nil
 }
 
+func list() (err error) {
+	b, err := io.ReadAll(os.Stdin)
+	if err != nil {
+		return err
+	}
+
+	ar := txtar.Parse(b)
+	for _, f := range ar.Files {
+		fmt.Println(f.Name)
+	}
+	return nil
+}
+
 func archive(paths []string) (err error) {
 	txtarHeader := regexp.MustCompile(`(?m)^-- .* --$`)
 
diff --git a/cmd/txtar/txtar_test.go b/cmd/txtar/txtar_test.go
index 2242240..35e319c 100644
--- a/cmd/txtar/txtar_test.go
+++ b/cmd/txtar/txtar_test.go
@@ -26,6 +26,12 @@
 three
 `
 
+var filelist = `
+one.txt
+dir/two.txt
+$SPECIAL_LOCATION/three.txt
+`[1:]
+
 func TestMain(m *testing.M) {
 	code := m.Run()
 	txtarBin.once.Do(func() {})
@@ -49,6 +55,18 @@
 	if err := os.Mkdir(dir, 0755); err != nil {
 		t.Fatal(err)
 	}
+
+	if out, err := txtar(t, dir, testdata, "--list"); err != nil {
+		t.Fatal(err)
+	} else if out != filelist {
+		t.Fatalf("txtar --list: stdout:\n%s\nwant:\n%s", out, filelist)
+	}
+	if entries, err := os.ReadDir(dir); err != nil {
+		t.Fatal(err)
+	} else if len(entries) > 0 {
+		t.Fatalf("txtar --list: did not expect any extracted files")
+	}
+
 	if out, err := txtar(t, dir, testdata, "--extract"); err != nil {
 		t.Fatal(err)
 	} else if out != comment {