siso: Introduce 'siso fs gc' subcommand for target build label cleanup

Adds the `siso fs gc` CLI subcommand to enable mathematical build label
garbage collection against the HashFS `.siso_fs_state` ledger.

This tool allows developers and continuous integration workers to sweep
orphaned or evicted build target artifacts from disk while preserving
shared libraries and header caches.

Key capabilities:
- Supports positional regex target eviction (`siso fs gc "target-.*"`)
- Supports LRU build cache bounding (`siso fs gc --retain_last_x=3`)
- Protects shared files using bitwise intersection (`(fileMask & keepMask) == 0`)
- Includes `--dry_run` flag for non-destructive simulation and reporting
- Exports structured metrics to `siso_metrics.json` for telemetry pipelines

Also introduces an E2E integration test suite under
`subcmd/ninja/e2etests/fs_gc_test.go` and wires `--build_label`
into `setupBuild` in `utils_test.go`.

This is the 4th CL for go/targeted-build-cleaning.

BUG=446692498
TAG=agy
Change-Id: I096d9ed17b0163142212f96b7d9d40f497abe4ad
Reviewed-on: https://chromium-review.googlesource.com/c/build/+/8183971
Reviewed-by: Yuke Liao <liaoyuke@google.com>
Commit-Queue: Saatvik Sharma <sharmasaatvik@google.com>
diff --git a/siso/hashfs/build_label_gc.go b/siso/hashfs/build_label_gc.go
index 55bd978..4c22b5c 100644
--- a/siso/hashfs/build_label_gc.go
+++ b/siso/hashfs/build_label_gc.go
@@ -36,6 +36,7 @@
 	TotalBytesReclaimed int64
 	EvictedBuildLabels  []string
 	FailedDeletions     []string
+	EvictedFiles        []string
 }
 
 // GarbageCollectBuildLabels deletes files exclusively generated by evicted build labels based on BuildLabelGCOptions.
@@ -49,11 +50,17 @@
 	successfullyDeleted, failedDeletions, totalDeleted, totalBytes, sweepErr := hfs.performActualDeletion(ctx, opts, toDelete)
 	hfs.updateLedger(opts, evictMask, successfullyDeleted)
 
+	evictedFiles := successfullyDeleted
+	if opts.DryRun {
+		evictedFiles = toDelete
+	}
+
 	res := BuildLabelGCResult{
 		TotalFilesDeleted:   totalDeleted,
 		TotalBytesReclaimed: totalBytes,
 		EvictedBuildLabels:  evictedBuildLabels,
 		FailedDeletions:     failedDeletions,
+		EvictedFiles:        evictedFiles,
 	}
 
 	return res, sweepErr
diff --git a/siso/subcmd/fscmd/build_label_gc.go b/siso/subcmd/fscmd/build_label_gc.go
new file mode 100644
index 0000000..f451843
--- /dev/null
+++ b/siso/subcmd/fscmd/build_label_gc.go
@@ -0,0 +1,207 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package fscmd
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"flag"
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"github.com/google/subcommands"
+
+	"go.chromium.org/build/siso/build/ninjabuild"
+	"go.chromium.org/build/siso/hashfs"
+	"go.chromium.org/build/siso/signals"
+)
+
+const gcUsage = `garbage collect files exclusive to specific targets.
+
+ $ siso fs gc -C <dir> [--retain_last_x N] [--dry_run] [--output_file <filename>] [<target_labels>...]
+
+It will scan the .siso_fs_state ledger for files that were exclusively
+generated by evicted build labels and physically delete them to free up disk space.
+`
+
+func (*gcCommand) Name() string {
+	return "gc"
+}
+
+func (*gcCommand) Synopsis() string {
+	return "garbage collect files exclusive to specific targets"
+}
+
+func (*gcCommand) Usage() string {
+	return gcUsage
+}
+
+type gcCommand struct {
+	Flags       *flag.FlagSet
+	outDir      ninjabuild.DirFlag
+	stateFile   string
+	retainLastX int
+	dryRun      bool
+	outputFile  string
+}
+
+func (c *gcCommand) SetFlags(flagSet *flag.FlagSet) {
+	c.outDir.RegisterFlags(flagSet)
+	flagSet.StringVar(&c.stateFile, "fs_state", stateFile, "fs_state filename")
+	flagSet.IntVar(&c.retainLastX, "retain_last_x", 0, "number of recent builds to retain (evicts all older targets)")
+	flagSet.BoolVar(&c.dryRun, "dry_run", false, "simulate garbage collection without deleting anything")
+	flagSet.StringVar(&c.outputFile, "output_file", "", "filename to write the list of evicted files")
+}
+
+func (c *gcCommand) Execute(ctx context.Context, flagSet *flag.FlagSet, _ ...any) subcommands.ExitStatus {
+	c.Flags = flagSet
+	err := c.run(ctx)
+	if err != nil {
+		switch {
+		case errors.Is(err, flag.ErrHelp):
+			fmt.Fprintf(os.Stderr, "%v\n%s\n", err, gcUsage)
+			return subcommands.ExitUsageError
+		default:
+			fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+			return subcommands.ExitFailure
+		}
+	}
+	return subcommands.ExitSuccess
+}
+
+func (c *gcCommand) run(ctx context.Context) (err error) {
+	ctx, cancel := context.WithCancel(ctx)
+	defer signals.HandleInterrupt(ctx, cancel)()
+
+	if c.retainLastX < 0 {
+		return fmt.Errorf("retain_last_x must be non-negative: %w", flag.ErrHelp)
+	}
+	evictLabels := c.Flags.Args()
+	if len(evictLabels) == 0 && c.retainLastX <= 0 {
+		return fmt.Errorf("must specify either target labels to evict or --retain_last_x: %w", flag.ErrHelp)
+	}
+	if len(evictLabels) > 0 && c.retainLastX > 0 {
+		return fmt.Errorf("cannot use both target labels and --retain_last_x simultaneously: %w", flag.ErrHelp)
+	}
+
+	startDir, workspaceRoot, dir, err := ninjabuild.InitDir(ctx, c.outDir)
+	if err != nil {
+		return fmt.Errorf("failed to init dir %q: %w", c.outDir.Dir, err)
+	}
+	defer func() {
+		_ = os.Chdir(startDir)
+	}()
+	wd := filepath.Join(workspaceRoot, dir)
+
+	hfs, err := hashfs.New(ctx, hashfs.Option{
+		StateFile: c.stateFile,
+	})
+	if err != nil {
+		return fmt.Errorf("failed to load %q: %w", c.stateFile, err)
+	}
+	defer func() {
+		if closeErr := hfs.Close(context.Background()); closeErr != nil && err == nil {
+			err = fmt.Errorf("failed to save HashFS state: %w", closeErr)
+		}
+	}()
+
+	// Wait for HashFS background initialization to complete (reading state file, etc.)
+	err = hfs.WaitReady(ctx)
+	if err != nil {
+		return fmt.Errorf("HashFS failed to become ready: %w", err)
+	}
+
+	if c.dryRun {
+		fmt.Printf("Simulating sweep of exclusively generated files... (Dry Run)\n")
+	} else {
+		fmt.Printf("Sweeping exclusively generated files...\n")
+	}
+
+	opts := hashfs.BuildLabelGCOptions{
+		Strategy: hashfs.BuildLabelGCSweepStrategy{
+			EvictLabels: evictLabels,
+			RetainLastX: c.retainLastX,
+		},
+		DryRun: c.dryRun,
+	}
+	result, err := hfs.GarbageCollectBuildLabels(ctx, opts)
+	if err != nil {
+		return err
+	}
+
+	if len(result.EvictedBuildLabels) > 0 {
+		fmt.Printf("\nEvicted Targets:\n")
+		for _, target := range result.EvictedBuildLabels {
+			fmt.Printf(" - %s\n", target)
+		}
+	}
+	if c.dryRun {
+		fmt.Printf("\nWould sweep %d file(s) (Total Reclaimed: %.2f MB).\n", result.TotalFilesDeleted, float64(result.TotalBytesReclaimed)/1024.0/1024.0)
+	} else {
+		fmt.Printf("\nSuccessfully swept %d file(s) (Total Reclaimed: %.2f MB).\n", result.TotalFilesDeleted, float64(result.TotalBytesReclaimed)/1024.0/1024.0)
+	}
+
+	if c.outputFile != "" {
+		if !filepath.IsAbs(c.outputFile) {
+			c.outputFile = filepath.Join(startDir, c.outputFile)
+		}
+		outPath := filepath.FromSlash(c.outputFile)
+		f, err := os.Create(outPath)
+		if err != nil {
+			return fmt.Errorf("failed to create output file %q: %w", c.outputFile, err)
+		}
+		var writeErr error
+		for _, file := range result.EvictedFiles {
+			if _, err := fmt.Fprintln(f, file); err != nil {
+				writeErr = err
+				break
+			}
+		}
+		if closeErr := f.Close(); closeErr != nil && writeErr == nil {
+			writeErr = closeErr
+		}
+		if writeErr != nil {
+			return fmt.Errorf("failed to write output file %q: %w", c.outputFile, writeErr)
+		}
+		fmt.Printf("Wrote list of %d evicted file(s) to %s\n", len(result.EvictedFiles), c.outputFile)
+	}
+
+	// Export metrics to siso_metrics.json for telemetry
+	metricsFile := filepath.Join(wd, "siso_metrics.json")
+
+	type gcMetrics struct {
+		Event               string   `json:"event"`
+		EvictLabels         []string `json:"evict_labels,omitempty"`
+		RetainLastX         int      `json:"retain_last_x,omitempty"`
+		DryRun              bool     `json:"dry_run"`
+		OutputFile          string   `json:"output_file,omitempty"`
+		EvictedBuildLabels  []string `json:"evicted_build_labels,omitempty"`
+		TotalFilesDeleted   int      `json:"total_files_deleted"`
+		TotalBytesReclaimed int64    `json:"total_bytes_reclaimed"`
+		FailedDeletions     int      `json:"failed_deletions,omitempty"`
+	}
+	m := gcMetrics{
+		Event:               "garbage_collection",
+		EvictLabels:         evictLabels,
+		RetainLastX:         c.retainLastX,
+		DryRun:              c.dryRun,
+		OutputFile:          c.outputFile,
+		EvictedBuildLabels:  result.EvictedBuildLabels,
+		TotalFilesDeleted:   result.TotalFilesDeleted,
+		TotalBytesReclaimed: result.TotalBytesReclaimed,
+		FailedDeletions:     len(result.FailedDeletions),
+	}
+	if metricData, err := json.Marshal(m); err == nil {
+		if f, err := os.OpenFile(metricsFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
+			metricData = append(metricData, '\n')
+			_, _ = f.Write(metricData)
+			f.Close()
+		}
+	}
+
+	return nil
+}
diff --git a/siso/subcmd/fscmd/build_label_gc_test.go b/siso/subcmd/fscmd/build_label_gc_test.go
new file mode 100644
index 0000000..204d60c
--- /dev/null
+++ b/siso/subcmd/fscmd/build_label_gc_test.go
@@ -0,0 +1,56 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package fscmd
+
+import (
+	"errors"
+	"flag"
+	"strings"
+	"testing"
+)
+
+func TestGcCommand_Run_ValidationErrors(t *testing.T) {
+	tests := []struct {
+		name          string
+		args          []string
+		wantErrSubstr string
+	}{
+		{
+			name:          "no args",
+			args:          []string{},
+			wantErrSubstr: "must specify either target labels to evict or --retain_last_x",
+		},
+		{
+			name:          "both args",
+			args:          []string{"--retain_last_x=3", "label1"},
+			wantErrSubstr: "cannot use both target labels and --retain_last_x simultaneously",
+		},
+		{
+			name:          "negative retain_last_x",
+			args:          []string{"--retain_last_x=-1"},
+			wantErrSubstr: "retain_last_x must be non-negative",
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			cmd := &gcCommand{}
+			fs := flag.NewFlagSet("gc", flag.ContinueOnError)
+			cmd.SetFlags(fs)
+			if err := fs.Parse(tc.args); err != nil {
+				t.Fatalf("fs.Parse(%v) err = %v, want nil", tc.args, err)
+			}
+			cmd.Flags = fs
+
+			err := cmd.run(t.Context())
+			if !errors.Is(err, flag.ErrHelp) {
+				t.Errorf("cmd.run(ctx) err = %v, want errors.Is(flag.ErrHelp)", err)
+			}
+			if err != nil && !strings.Contains(err.Error(), tc.wantErrSubstr) {
+				t.Errorf("cmd.run(ctx) err = %v, want substring %q", err, tc.wantErrSubstr)
+			}
+		})
+	}
+}
diff --git a/siso/subcmd/fscmd/fscmd.go b/siso/subcmd/fscmd/fscmd.go
index 6e4d71b..3045936 100644
--- a/siso/subcmd/fscmd/fscmd.go
+++ b/siso/subcmd/fscmd/fscmd.go
@@ -51,6 +51,7 @@
 	commander.Register(&diffCommand{}, "")
 	commander.Register(&exportCommand{}, "")
 	commander.Register(&flushCommand{authOpts: c.authOpts}, "")
+	commander.Register(&gcCommand{}, "")
 	commander.Register(&importCommand{}, "")
 	commander.Register(&statusCommand{}, "")
 	commander.Register(commander.HelpCommand(), "command-help")
diff --git a/siso/subcmd/ninja/e2etests/fs_gc_test.go b/siso/subcmd/ninja/e2etests/fs_gc_test.go
new file mode 100644
index 0000000..e8be8ed
--- /dev/null
+++ b/siso/subcmd/ninja/e2etests/fs_gc_test.go
@@ -0,0 +1,199 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package e2etests
+
+import (
+	"context"
+	"errors"
+	"flag"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/google/subcommands"
+
+	"go.chromium.org/build/siso/auth/cred"
+	"go.chromium.org/build/siso/build/ninjabuild"
+	"go.chromium.org/build/siso/hashfs"
+	"go.chromium.org/build/siso/subcmd/fscmd"
+)
+
+const stateFileName = ".siso_fs_state"
+
+func setupGCTestDir(t *testing.T) (context.Context, string) {
+	t.Helper()
+	ctx := t.Context()
+	dir := tempDir(t)
+
+	if err := os.MkdirAll(filepath.Join(dir, "build/config/siso"), 0755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.MkdirAll(filepath.Join(dir, "out/siso"), 0755); err != nil {
+		t.Fatal(err)
+	}
+
+	setupFiles(t, dir, "TestBuild_FSGC_E2E", nil)
+	if err := os.Remove(filepath.Join(dir, "out/siso", stateFileName)); err != nil && !errors.Is(err, os.ErrNotExist) {
+		t.Fatal(err)
+	}
+	return ctx, dir
+}
+
+func runNinjaWithLabel(ctx context.Context, t *testing.T, dir, label, ninjaFile string) {
+	t.Helper()
+	original := filepath.Join(dir, "out/siso", ninjaFile)
+	dest := filepath.Join(dir, "out/siso/build.ninja")
+	originalB, err := os.ReadFile(original)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(dest, originalB, 0644); err != nil {
+		t.Fatal(err)
+	}
+
+	opt, graph, cleanup := setupBuild(ctx, t, dir, hashfs.Option{StateFile: stateFileName, BuildLabel: label})
+	defer cleanup()
+
+	_, err = ninjabuild.Run(ctx, graph, opt, nil, ninjabuild.RunNinjaOpts{})
+	if err != nil {
+		t.Fatalf("ninja err for %s, got=%v; want=<nil>", label, err)
+	}
+}
+
+func runGC(ctx context.Context, t *testing.T, dir string, args ...string) error {
+	t.Helper()
+	cmd := fscmd.Cmd(cred.Options{})
+	fs := flag.NewFlagSet("fs", flag.ContinueOnError)
+	cmd.SetFlags(fs)
+	fullArgs := append([]string{"gc", "-C", filepath.Join(dir, "out/siso"), "--fs_state", stateFileName}, args...)
+	if err := fs.Parse(fullArgs); err != nil {
+		return err
+	}
+	status := cmd.Execute(ctx, fs)
+	if status != subcommands.ExitSuccess {
+		return fmt.Errorf("fscmd gc exited with status %v", status)
+	}
+	return nil
+}
+
+func TestBuild_FSGC_EvictTarget(t *testing.T) {
+	if !runInSubProcess(t) {
+		return
+	}
+	ctx, dir := setupGCTestDir(t)
+
+	runNinjaWithLabel(ctx, t, dir, "target-1", "build.ninja.0")
+	for _, fname := range []string{"out/siso/obj/bar.o", "out/siso/obj/foo.o"} {
+		if _, err := os.Stat(filepath.Join(dir, fname)); err != nil {
+			t.Fatalf("stat(%s) err, got=%v; want=<nil>", fname, err)
+		}
+	}
+
+	time.Sleep(50 * time.Millisecond)
+
+	runNinjaWithLabel(ctx, t, dir, "target-2", "build.ninja.1")
+	for _, fname := range []string{"out/siso/obj/bar.o", "out/siso/obj/foo.o", "out/siso/obj/baz.o"} {
+		if _, err := os.Stat(filepath.Join(dir, fname)); err != nil {
+			t.Fatalf("stat(%s) err, got=%v; want=<nil>", fname, err)
+		}
+	}
+
+	err := runGC(ctx, t, dir, "target-1")
+	if err != nil {
+		t.Fatalf("runGC err, got=%v; want=<nil>", err)
+	}
+
+	// Validate obj/bar.o is physically deleted (exclusive to target-1)
+	if _, err := os.Stat(filepath.Join(dir, "out/siso/obj/bar.o")); !errors.Is(err, os.ErrNotExist) {
+		t.Errorf("stat(obj/bar.o) err, got=%v; want=os.ErrNotExist", err)
+	}
+	// Validate obj/foo.o remains intact (preventing accidental shared cache purges - used by target-2)
+	if _, err := os.Stat(filepath.Join(dir, "out/siso/obj/foo.o")); err != nil {
+		t.Errorf("stat(obj/foo.o) err, got=%v; want=<nil>", err)
+	}
+}
+
+func TestBuild_FSGC_RetainLastX(t *testing.T) {
+	if !runInSubProcess(t) {
+		return
+	}
+	ctx, dir := setupGCTestDir(t)
+
+	runNinjaWithLabel(ctx, t, dir, "target-1", "build.ninja.0")
+	time.Sleep(50 * time.Millisecond)
+	runNinjaWithLabel(ctx, t, dir, "target-2", "build.ninja.1")
+
+	err := runGC(ctx, t, dir, "--retain_last_x=1")
+	if err != nil {
+		t.Fatalf("runGC err, got=%v; want=<nil>", err)
+	}
+
+	// Older targets (target-1) fall out, exclusively owned artifacts are evicted.
+	if _, err := os.Stat(filepath.Join(dir, "out/siso/obj/bar.o")); !errors.Is(err, os.ErrNotExist) {
+		t.Errorf("stat(obj/bar.o) err, got=%v; want=os.ErrNotExist", err)
+	}
+	if _, err := os.Stat(filepath.Join(dir, "out/siso/obj/foo.o")); err != nil {
+		t.Errorf("stat(obj/foo.o) err, got=%v; want=<nil>", err)
+	}
+	if _, err := os.Stat(filepath.Join(dir, "out/siso/obj/baz.o")); err != nil {
+		t.Errorf("stat(obj/baz.o) err, got=%v; want=<nil>", err)
+	}
+}
+
+func TestBuild_FSGC_DryRun(t *testing.T) {
+	if !runInSubProcess(t) {
+		return
+	}
+	ctx, dir := setupGCTestDir(t)
+
+	runNinjaWithLabel(ctx, t, dir, "target-1", "build.ninja.0")
+
+	outputFile := filepath.Join(dir, "evicted.txt")
+	err := runGC(ctx, t, dir, "--dry_run", "--output_file", outputFile, "target-1")
+	if err != nil {
+		t.Fatalf("runGC err, got=%v; want=<nil>", err)
+	}
+
+	evictedFilesContent, err := os.ReadFile(outputFile)
+	if err != nil {
+		t.Fatalf("os.ReadFile(%s) err, got=%v; want=<nil>", outputFile, err)
+	}
+	evictedFiles := strings.Split(strings.TrimSpace(string(evictedFilesContent)), "\n")
+	if len(evictedFiles) != 7 {
+		t.Errorf("len(evictedFiles), got=%d; want=7", len(evictedFiles))
+	}
+
+	// Confirm zero files were dropped, obj/foo.o and obj/bar.o still exist
+	if _, err := os.Stat(filepath.Join(dir, "out/siso/obj/foo.o")); err != nil {
+		t.Errorf("stat(obj/foo.o) dry run err, got=%v; want=<nil>", err)
+	}
+	if _, err := os.Stat(filepath.Join(dir, "out/siso/obj/bar.o")); err != nil {
+		t.Errorf("stat(obj/bar.o) dry run err, got=%v; want=<nil>", err)
+	}
+}
+
+func TestBuild_FSGC_ValidationErrors(t *testing.T) {
+	if !runInSubProcess(t) {
+		return
+	}
+	ctx, dir := setupGCTestDir(t)
+
+	t.Run("NoStrategy", func(t *testing.T) {
+		err := runGC(ctx, t, dir)
+		if err == nil {
+			t.Errorf("runGC empty strategy err, got=<nil>; want=error")
+		}
+	})
+
+	t.Run("ConflictingStrategies", func(t *testing.T) {
+		err := runGC(ctx, t, dir, "--retain_last_x=1", "target-1")
+		if err == nil {
+			t.Errorf("runGC conflicting strategy err, got=<nil>; want=error")
+		}
+	})
+}
diff --git a/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/base/bar.cc b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/base/bar.cc
new file mode 100644
index 0000000..09af8af
--- /dev/null
+++ b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/base/bar.cc
@@ -0,0 +1,6 @@
+/*
+ * Copyright 2026 The Chromium Authors
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+/* bar.cc */
diff --git a/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/base/bar.h.in b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/base/bar.h.in
new file mode 100644
index 0000000..2c2cbc0
--- /dev/null
+++ b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/base/bar.h.in
@@ -0,0 +1,6 @@
+/*
+ * Copyright 2026 The Chromium Authors
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+/* bar.h.in */
diff --git a/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/base/baz.cc b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/base/baz.cc
new file mode 100644
index 0000000..749d6dd
--- /dev/null
+++ b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/base/baz.cc
@@ -0,0 +1,6 @@
+/*
+ * Copyright 2026 The Chromium Authors
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+/* baz.cc */
diff --git a/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/base/baz.h.in b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/base/baz.h.in
new file mode 100644
index 0000000..48ef5cb
--- /dev/null
+++ b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/base/baz.h.in
@@ -0,0 +1,6 @@
+/*
+ * Copyright 2026 The Chromium Authors
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+/* baz.h.in */
diff --git a/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/base/foo.cc b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/base/foo.cc
new file mode 100644
index 0000000..901eb0a
--- /dev/null
+++ b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/base/foo.cc
@@ -0,0 +1,6 @@
+/*
+ * Copyright 2026 The Chromium Authors
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+/* foo.cc */
diff --git a/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/base/foo.h.in b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/base/foo.h.in
new file mode 100644
index 0000000..a5df828
--- /dev/null
+++ b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/base/foo.h.in
@@ -0,0 +1,6 @@
+/*
+ * Copyright 2026 The Chromium Authors
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+/* foo.h.in */
diff --git a/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/build/config/siso/main.star b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/build/config/siso/main.star
new file mode 100644
index 0000000..3bdb027
--- /dev/null
+++ b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/build/config/siso/main.star
@@ -0,0 +1,33 @@
+# Copyright 2026 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+load("@builtin//encoding.star", "json")
+load("@builtin//struct.star", "module")
+
+def __copy(ctx, cmd):
+    input = cmd.inputs[0]
+    out = cmd.outputs[0]
+    ctx.actions.copy(input, out, recursive = ctx.fs.is_dir(input))
+    ctx.actions.exit(exit_status = 0)
+
+__handlers = {
+    "copy": __copy,
+}
+
+def init(ctx):
+    step_config = {
+        "rules": [
+            {
+                "name": "simple/copy",
+                "action": "copy",
+                "handler": "copy",
+            },
+        ],
+    }
+    return module(
+        "config",
+        step_config = json.encode(step_config),
+        filegroups = {},
+        handlers = __handlers,
+    )
diff --git a/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/cache/data/data.txt b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/cache/data/data.txt
new file mode 100644
index 0000000..5b5036e
--- /dev/null
+++ b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/cache/data/data.txt
@@ -0,0 +1,3 @@
+# Copyright 2026 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
diff --git a/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/cache/info.txt b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/cache/info.txt
new file mode 100644
index 0000000..5b5036e
--- /dev/null
+++ b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/cache/info.txt
@@ -0,0 +1,3 @@
+# Copyright 2026 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
diff --git a/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/out/siso/build.ninja.0 b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/out/siso/build.ninja.0
new file mode 100644
index 0000000..b6cf80d
--- /dev/null
+++ b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/out/siso/build.ninja.0
@@ -0,0 +1,36 @@
+# Copyright 2026 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+#
+# difference between build.ninja.0 and build.ninja.1
+#  build.ninja.0: build bar*
+#  build.ninja.1: build baz*, so bar* will be dead files.
+
+rule cxx
+  command = python3 ../../tools/clang++.py -MF ${out}.d -o${out} -c ${in}
+  deps = gcc
+  depfile = ${out}.d
+
+rule link
+  command = python3 ../../tools/clang++.py -o${out} ${in}
+
+rule gen
+  command = python3 ../../tools/gen.py ${in} ${out}
+
+rule copy
+  command = ln -f ${in} ${out} 2>/dev/null || (rm -rf ${out} && cp -af ${in} ${out})
+
+build gen/cache: copy ../../cache
+
+build gen/foo.h: gen ../../base/foo.h.in | gen/cache
+build gen/bar.h: gen ../../base/bar.h.in | gen/cache
+
+build obj/foo.o: cxx ../../base/foo.cc | gen/foo.h
+build obj/bar.o: cxx ../../base/bar.cc | gen/bar.h
+
+build target: link obj/foo.o obj/bar.o
+
+build all: phony target
+
+build build.ninja: phony
+
diff --git a/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/out/siso/build.ninja.1 b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/out/siso/build.ninja.1
new file mode 100644
index 0000000..87eef97
--- /dev/null
+++ b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/out/siso/build.ninja.1
@@ -0,0 +1,36 @@
+# Copyright 2026 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+#
+# difference between build.ninja.0 and build.ninja.1
+#  build.ninja.0: build bar*
+#  build.ninja.1: build baz*, so bar* will be dead files.
+
+rule cxx
+  command = python3 ../../tools/clang++.py -MF ${out}.d -o${out} -c ${in}
+  deps = gcc
+  depfile = ${out}.d
+
+rule link
+  command = python3 ../../tools/clang++.py -o${out} ${in}
+
+rule gen
+  command = python3 ../../tools/gen.py ${in} ${out}
+
+rule copy
+  command = ln -f ${in} ${out} 2>/dev/null || (rm -rf ${out} && cp -af ${in} ${out})
+
+build gen/cache: copy ../../cache
+
+build gen/foo.h: gen ../../base/foo.h.in | gen/cache
+build gen/baz.h: gen ../../base/baz.h.in | gen/cache
+
+build obj/foo.o: cxx ../../base/foo.cc | gen/foo.h
+build obj/baz.o: cxx ../../base/baz.cc | gen/baz.h
+
+build target: link obj/foo.o obj/baz.o
+
+build all: phony target
+
+build build.ninja: phony
+
diff --git a/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/tools/clang++.py b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/tools/clang++.py
new file mode 100644
index 0000000..fda3846
--- /dev/null
+++ b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/tools/clang++.py
@@ -0,0 +1,33 @@
+# Copyright 2026 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import argparse
+import os
+import sys
+
+
+def main():
+  parser = argparse.ArgumentParser()
+  parser.add_argument("-MF", help="deps filename")
+  parser.add_argument("-o", help="output filename")
+  parser.add_argument("-c", help="compile", action='store_true')
+  parser.add_argument("inputs", nargs='*')
+  options = parser.parse_args()
+
+  if options.c:
+    with open(options.o, "w") as f:
+      f.write("compile result of %s" % options.inputs)
+    if options.MF:
+      with open(options.MF, "w") as f:
+        f.write("%s:" % options.o)
+        for input in options.inputs:
+          f.write(" %s" % input)
+    return 0
+  with open(options.o, "w") as f:
+    f.write("link result of %s" % options.inputs)
+  return 0
+
+
+if __name__ == "__main__":
+  sys.exit(main())
diff --git a/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/tools/gen.py b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/tools/gen.py
new file mode 100644
index 0000000..60dec0c
--- /dev/null
+++ b/siso/subcmd/ninja/e2etests/testdata/TestBuild_FSGC_E2E/tools/gen.py
@@ -0,0 +1,23 @@
+# Copyright 2026 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import argparse
+import os
+import sys
+
+
+def main():
+  parser = argparse.ArgumentParser()
+  parser.add_argument("input")
+  parser.add_argument("output")
+  options = parser.parse_args()
+
+  with open(options.output, "w") as w:
+    with open(options.input) as r:
+      w.write(r.read())
+  return 0
+
+
+if __name__ == "__main__":
+  sys.exit(main())
diff --git a/siso/subcmd/ninja/e2etests/utils_test.go b/siso/subcmd/ninja/e2etests/utils_test.go
index d051448..7f3d1a5 100644
--- a/siso/subcmd/ninja/e2etests/utils_test.go
+++ b/siso/subcmd/ninja/e2etests/utils_test.go
@@ -373,8 +373,10 @@
 		}
 	})
 	opt := build.Options{
-		Path:            path,
-		HashFS:          hashFS,
+		Path:   path,
+		HashFS: hashFS,
+		// Forward active build label from fsopt so test builds tag output artifacts in .siso_fs_state.
+		BuildLabel:      fsopt.BuildLabel,
 		REExecEnable:    true,
 		Cache:           cache,
 		FailuresAllowed: 1,