Skip to content

Commit

Permalink
Autoclean (#33)
Browse files Browse the repository at this point in the history
* Autoclean.

* Configure some additional linters and fix a few things they found.
  • Loading branch information
bobg authored May 13, 2023
1 parent 64ad97a commit 234bc71
Show file tree
Hide file tree
Showing 16 changed files with 216 additions and 68 deletions.
5 changes: 5 additions & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
linters:
enable:
- gocritic
- godot
- revive
95 changes: 71 additions & 24 deletions clean.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,61 @@ import (
"context"
"io/fs"
"os"
"sort"
"sync"

"github.com/bobg/errors"
"github.com/bobg/go-generics/v2/set"
"gopkg.in/yaml.v3"
)

// Clean is a Target that deletes the files named in Files when it runs.
// Files that already don't exist are silently ignored.
//
// A Clean target may be specified in YAML using the tag !Clean,
// which introduces a sequence.
// The elements of the sequence are interpreted by [YAMLStringListFromNodes]
// to produce the list of files for the target.
// If Autoclean is true,
// files listed in the "autoclean registry" are also removed.
// See [Autoclean] for more about this feature.
//
// A Clean target may be specified in YAML using the tag !Clean.
// It may introduce a sequence,
// in which case the elements are files to delete,
// or a mapping with fields `Files`,
// the files to delete,
// and `Autoclean`,
// a boolean for enabling the autoclean feature.
//
// When [GetDryRun] is true,
// Clean will not remove any files.
func Clean(files ...string) Target {
return &clean{
Files: files,
}
}

type clean struct {
Files []string
type Clean struct {
Files []string
Autoclean bool
}

// Run implements Target.Run.
func (c *clean) Run(ctx context.Context, con *Controller) error {
func (c *Clean) Run(ctx context.Context, con *Controller) error {
files := c.Files
if c.Autoclean {
autocleanMu.Lock()
autocleanFiles := autocleanRegistry.Slice()
files = append(files, autocleanFiles...)
autocleanMu.Unlock()
}
sort.Strings(files)

if len(files) == 0 {
return nil
}

if GetDryRun(ctx) {
if GetVerbose(ctx) {
con.Indentf(" would remove %v", c.Files)
con.Indentf(" would remove %v", files)
}
return nil
}
if GetVerbose(ctx) {
con.Indentf(" removing %v", c.Files)
con.Indentf(" removing %v", files)
}
for _, f := range c.Files {
for _, f := range files {
err := os.Remove(f)
if errors.Is(err, fs.ErrNotExist) {
continue
Expand All @@ -53,19 +71,48 @@ func (c *clean) Run(ctx context.Context, con *Controller) error {
}

// Desc implements Target.Desc.
func (*clean) Desc() string {
func (*Clean) Desc() string {
return "Clean"
}

var (
autocleanMu sync.Mutex
autocleanRegistry = set.New[string]()
)

func cleanDecoder(con *Controller, node *yaml.Node, dir string) (Target, error) {
if node.Kind != yaml.SequenceNode {
return nil, BadYAMLNodeKindError{Got: node.Kind, Want: yaml.SequenceNode}
}
files, err := con.YAMLFileListFromNodes(node.Content, dir)
if err != nil {
return nil, errors.Wrap(err, "YAML error in Clean node")
var (
files []string
autoclean bool
err error
)

switch node.Kind {
case yaml.MappingNode:
var yclean struct {
Files yaml.Node `yaml:"Files"`
Autoclean bool `yaml:"Autoclean"`
}
if err = node.Decode(&yclean); err != nil {
return nil, errors.Wrap(err, "YAML error in Clean node")
}
files, err = con.YAMLFileList(&yclean.Files, dir)
if err != nil {
return nil, errors.Wrap(err, "YAML error in Clean.Files node")
}
autoclean = yclean.Autoclean

case yaml.SequenceNode:
files, err = con.YAMLFileListFromNodes(node.Content, dir)
if err != nil {
return nil, errors.Wrap(err, "YAML error in Clean node children")
}

default:
return nil, BadYAMLNodeKindError{Got: node.Kind, Want: yaml.MappingNode | yaml.SequenceNode}
}
return Clean(files...), nil

return &Clean{Files: files, Autoclean: autoclean}, nil
}

func init() {
Expand Down
49 changes: 48 additions & 1 deletion clean_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io/fs"
"os"
"path/filepath"
"testing"

"github.com/bobg/errors"
Expand All @@ -24,7 +25,13 @@ func TestClean(t *testing.T) {
}

con := NewController("")
if err = con.Run(context.Background(), Clean(tmpname, "/tmp/i-hope-i-am-a-file-that-does-not-exist")); err != nil {
clean := &Clean{
Files: []string{
tmpname,
"/tmp/i-hope-i-am-a-file-that-does-not-exist",
},
}
if err = con.Run(context.Background(), clean); err != nil {
t.Fatal(err)
}

Expand All @@ -38,3 +45,43 @@ func TestClean(t *testing.T) {
t.Errorf("failed to remove %s", tmpname)
}
}

func TestAutoclean(t *testing.T) {
tmpdir, err := os.MkdirTemp("", "fab")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpdir)

path := filepath.Join(tmpdir, "outfile")

mkfile := F(func(context.Context, *Controller) error {
return os.WriteFile(path, []byte("Professor Little Old Man!"), 0644)
})
files := Files(mkfile, nil, []string{path}, Autoclean(true))

var (
con = NewController("")
ctx = context.Background()
)

ctx = WithVerbose(ctx, testing.Verbose())

if err = con.Run(ctx, files); err != nil {
t.Fatal(err)
}

_, err = os.Stat(path)
if err != nil {
t.Fatal(err)
}

if err = con.Run(ctx, &Clean{Autoclean: true}); err != nil {
t.Fatal(err)
}

_, err = os.Stat(path)
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("got %v, want %v", err, fs.ErrNotExist)
}
}
11 changes: 7 additions & 4 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,16 +258,19 @@ func (c *Command) Run(ctx context.Context, con *Controller) (err error) {
}

if stderrFile != "" {
if stdoutFile == stderrFile {
switch {
case stdoutFile == stderrFile:
cmd.Stderr = cmd.Stdout
} else if stderrAppend {

case stderrAppend:
f, err := os.OpenFile(stderrFile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
if err != nil {
return errors.Wrapf(err, "opening %s for appending", stderrFile)
}
defer f.Close()
cmd.Stderr = f
} else {

default:
f, err := os.Create(stderrFile)
if err != nil {
return errors.Wrapf(err, "opening %s for writing", stderrFile)
Expand Down Expand Up @@ -504,7 +507,7 @@ func (c commandYAML) toTarget(con *Controller, shell, dir string, args, env []st

func deferredIndent(w io.Writer) func(context.Context, *Controller) io.Writer {
return func(_ context.Context, con *Controller) io.Writer {
return con.IndentingCopier(os.Stdout, " ")
return con.IndentingCopier(w, " ")
}
}

Expand Down
2 changes: 1 addition & 1 deletion driver.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func main() {
os.Exit(1)
}

db, err := fab.OpenHashDB(ctx, fabdir)
db, err := fab.OpenHashDB(fabdir)
if err != nil {
fatalf("Error opening hash DB: %s", err)
}
Expand Down
4 changes: 2 additions & 2 deletions fab.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ CoverOut: !Files
Out: [cover.out]
Target: !Command
Shell: go test -coverprofile cover.out ./...
Autoclean: true

# Clean removes build-target output.
Clean: !Clean
- fab
- cover.out
Autoclean: true
50 changes: 40 additions & 10 deletions files.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ var filesRegistry = newRegistry[*files]()
// then the output files are up to date and running of this Files target can be skipped.
//
// The nested subtarget must be of a type that can be JSON-marshaled.
// Note that this excludes [F],
// among others.
// Notably this excludes [F].
//
// When a Files target runs,
// it checks to see whether any of its input files
Expand All @@ -46,6 +45,12 @@ var filesRegistry = newRegistry[*files]()
// See the Deps function in the golang subpackage
// for an example of a function that can compute such a list for a Go package.
//
// Passing Autoclean(true) as one of the options
// causes the output files to be added to the "autoclean registry."
// A [Clean] target may then choose to remove the files listed in that registry
// (instead of, or in addition to, any explicitly listed files)
// by setting its Autoclean field to true.
//
// When [GetDryRun] is true,
// checking and updating of the hash DB is skipped.
//
Expand All @@ -70,13 +75,17 @@ var filesRegistry = newRegistry[*files]()
// which runs the given `go build` command
// to update the output file `thingify`
// when any files depended on by the Go package in `cmd` change.
func Files(target Target, in, out []string) Target {
func Files(target Target, in, out []string, opts ...FilesOpt) Target {
result := &files{
Target: target,
In: in,
Out: out,
}

for _, opt := range opts {
opt(result)
}

for _, o := range out {
filesRegistry.add(o, result)
}
Expand All @@ -101,7 +110,7 @@ func (ft *files) Run(ctx context.Context, con *Controller) error {
db := GetHashDB(ctx)

if db != nil && !GetForce(ctx) && !GetDryRun(ctx) {
h, err := ft.computeHash(ctx, con)
h, err := ft.computeHash(con)
if err != nil {
return errors.Wrap(err, "computing hash before running subtarget")
}
Expand All @@ -125,7 +134,7 @@ func (ft *files) Run(ctx context.Context, con *Controller) error {
return nil
}

h, err := ft.computeHash(ctx, con)
h, err := ft.computeHash(con)
if err != nil {
return errors.Wrap(err, "computing hash after running subtarget")
}
Expand All @@ -138,7 +147,7 @@ func (*files) Desc() string {
return "Files"
}

func (ft *files) computeHash(ctx context.Context, con *Controller) ([]byte, error) {
func (ft *files) computeHash(con *Controller) ([]byte, error) {
inHashes, err := fileHashes(ft.In)
if err != nil {
return nil, errors.Wrapf(err, "computing input hash(es) for %s", con.Describe(ft))
Expand Down Expand Up @@ -183,6 +192,26 @@ func (ft *files) runPrereqs(ctx context.Context, con *Controller) error {
return con.Run(ctx, prereqs...)
}

type FilesOpt func(*files)

// Autoclean is an option for passing to [Files].
// It causes the output files of the Files target to be added to the "autoclean registry."
// A [Clean] target may then choose to remove the files listed in that registry
// (instead of, or in addition to, any explicitly listed files)
// by setting its Autoclean field to true.
func Autoclean(autoclean bool) FilesOpt {
return func(f *files) {
if !autoclean {
return
}
autocleanMu.Lock()
for _, file := range f.Out {
autocleanRegistry.Add(file)
}
autocleanMu.Unlock()
}
}

// Returns [filename, hash, filename, hash, ...],
// with filenames sorted.
// Input is a list of file or directory names.
Expand Down Expand Up @@ -266,9 +295,10 @@ func filesDecoder(con *Controller, node *yaml.Node, dir string) (Target, error)
}

var yfiles struct {
In yaml.Node `yaml:"In"`
Out yaml.Node `yaml:"Out"`
Target yaml.Node `yaml:"Target"`
In yaml.Node `yaml:"In"`
Out yaml.Node `yaml:"Out"`
Target yaml.Node `yaml:"Target"`
Autoclean bool `yaml:"Autoclean"`
}
if err := node.Decode(&yfiles); err != nil {
return nil, errors.Wrap(err, "YAML error in Files node")
Expand All @@ -289,7 +319,7 @@ func filesDecoder(con *Controller, node *yaml.Node, dir string) (Target, error)
return nil, errors.Wrap(err, "YAML error in Files.Out node")
}

return Files(target, in, out), nil
return Files(target, in, out, Autoclean(yfiles.Autoclean)), nil
}

func init() {
Expand Down
6 changes: 5 additions & 1 deletion golang/go.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import (
// it defaults to the last path element of dir.
// Additional command-line arguments for `go build` can be specified with `flags`.
//
// Binary is implemented in terms of [fab.Files],
// and the output file is automatically selected for "autocleaning."
// See [fab.Autoclean] for more about this feature.
//
// A Binary target may be specified in YAML using the tag !go.Binary,
// which introduces a mapping whose fields are:
//
Expand Down Expand Up @@ -48,7 +52,7 @@ func Binary(dir, outfile string, flags ...string) (fab.Target, error) {
Cmd: "go",
Args: args,
}
return fab.Files(c, deps, []string{outfile}), nil
return fab.Files(c, deps, []string{outfile}, fab.Autoclean(true)), nil
}

// MustBinary is the same as [Binary] but panics on error.
Expand Down
Loading

0 comments on commit 234bc71

Please sign in to comment.