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,