diff --git a/docs/compiler/generated-output.md b/docs/compiler/generated-output.md index d38f4f9c..cee54e45 100644 --- a/docs/compiler/generated-output.md +++ b/docs/compiler/generated-output.md @@ -77,6 +77,10 @@ The public `gowdk manifest` command is documented in - Keep generated Go deterministic and formatted with `go/format`. - Keep generated browser code compiler-owned; user JavaScript remains optional assets or explicit page code. +- Disk static output and generated app source are planned before publication. + Final file replacements use same-directory temporary files followed by atomic + rename, so validation, formatting, manifest/report generation, and security + manifest generation fail before touching committed files. - Document new generated files in this page, the build report page, or the reference page that owns the public contract. - New generated artifact kinds must declare whether they are public, diff --git a/internal/appgen/appgen.go b/internal/appgen/appgen.go index 55214e19..6e3cf488 100644 --- a/internal/appgen/appgen.go +++ b/internal/appgen/appgen.go @@ -127,24 +127,23 @@ func GenerateWithPlan(outputDir, appDir string, plan ApplicationPlan) (result Re if isSameOrWithin(targetOutput, absOutput) { return Result{}, fmt.Errorf("build output directory %q must not be inside generated app output directory %q", absOutput, targetOutput) } - if err := os.MkdirAll(absApp, 0o755); err != nil { - return Result{}, err - } moduleContext := resolveGeneratedModuleContext(absApp) - if err := os.MkdirAll(targetOutput, 0o755); err != nil { - return Result{}, err - } - - files, err := copyOutputFiles(absOutput, targetOutput) + files, outputFiles, err := collectOutputFiles(absOutput, targetOutput) if err != nil { return Result{}, err } - if err := removeStaleOutputFiles(targetOutput, files); err != nil { - return Result{}, err - } - modulePath, err := writeGeneratedModuleFile(absApp, moduleContext, options) - if err != nil { - return Result{}, err + plannedFiles := append([]plannedFile(nil), outputFiles...) + var removeAfterPublish []string + modulePath := filepath.Join(absApp, modFileName) + if moduleContext.Nested { + modulePayload, err := moduleSource(options) + if err != nil { + return Result{}, err + } + plannedFiles = append(plannedFiles, plannedFile{path: modulePath, contents: []byte(modulePayload)}) + } else { + removeAfterPublish = append(removeAfterPublish, modulePath) + modulePath = "" } packageSource, err := appPackageSource(options) if err != nil { @@ -154,11 +153,23 @@ func GenerateWithPlan(outputDir, appDir string, plan ApplicationPlan) (result Re if err != nil { return Result{}, err } - if err := writeFileIfChanged(filepath.Join(absApp, appFileName), appSource); err != nil { + plannedFiles = append(plannedFiles, plannedFile{path: filepath.Join(absApp, appFileName), contents: appSource}) + lifecycleSources, err := lifecycleServiceFileSources(options) + if err != nil { return Result{}, err } - if err := writeLifecycleServiceFiles(absApp, options); err != nil { - return Result{}, err + for _, name := range []string{lifecycleFileName, lifecycleJSName} { + path := filepath.Join(absApp, name) + source, ok := lifecycleSources[name] + if !ok { + removeAfterPublish = append(removeAfterPublish, path) + continue + } + formatted, err := formatGeneratedGo(name, source) + if err != nil { + return Result{}, err + } + plannedFiles = append(plannedFiles, plannedFile{path: path, contents: formatted}) } auditTestSource, err := GeneratedAuditTestSource(options) if err != nil { @@ -166,29 +177,46 @@ func GenerateWithPlan(outputDir, appDir string, plan ApplicationPlan) (result Re } auditTestPath := filepath.Join(absApp, auditTestFileName) if len(auditTestSource) > 0 { - if err := writeFileIfChanged(auditTestPath, auditTestSource); err != nil { - return Result{}, err - } - } else if err := os.Remove(auditTestPath); err != nil && !os.IsNotExist(err) { - return Result{}, err + plannedFiles = append(plannedFiles, plannedFile{path: auditTestPath, contents: auditTestSource}) + } else { + removeAfterPublish = append(removeAfterPublish, auditTestPath) } - scriptFiles, err := writeInlineGoBlockFiles(absApp, options) + scriptFiles, scriptPlannedFiles, err := collectInlineGoBlockFiles(absApp, options) if err != nil { return Result{}, err } - addonGoBlockFiles, err := writeAddonGoBlockFiles(absApp, options) + plannedFiles = append(plannedFiles, scriptPlannedFiles...) + addonGoBlockFiles, addonPlannedFiles, err := collectAddonGoBlockFiles(absApp, options) if err != nil { return Result{}, err } + plannedFiles = append(plannedFiles, addonPlannedFiles...) files = append(files, scriptFiles...) files = append(files, addonGoBlockFiles...) mainSource, err := serverMainSource(moduleContext.ImportBase + "/" + appPackageDirName) if err != nil { return Result{}, err } - if err := writeFileIfChanged(filepath.Join(absApp, mainFileName), []byte(mainSource)); err != nil { + plannedFiles = append(plannedFiles, plannedFile{path: filepath.Join(absApp, mainFileName), contents: []byte(mainSource)}) + if err := os.MkdirAll(absApp, 0o755); err != nil { return Result{}, err } + if err := os.MkdirAll(targetOutput, 0o755); err != nil { + return Result{}, err + } + for _, file := range plannedFiles { + if err := writeFileIfChanged(file.path, file.contents); err != nil { + return Result{}, err + } + } + if err := removeStaleOutputFiles(targetOutput, files); err != nil { + return Result{}, err + } + for _, path := range removeAfterPublish { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return Result{}, err + } + } return Result{ AppDir: absApp, diff --git a/internal/appgen/appgen_test.go b/internal/appgen/appgen_test.go index ab8167cb..de211b9f 100644 --- a/internal/appgen/appgen_test.go +++ b/internal/appgen/appgen_test.go @@ -6332,6 +6332,12 @@ func TestGenerateRejectsUnsupportedAddonGoBlockTarget(t *testing.T) { if !strings.Contains(err.Error(), "requires an enabled addon implementing gowdk.GoBlockConsumer") { t.Fatalf("unexpected error: %v", err) } + if _, statErr := os.Stat(filepath.Join(appDir, appOutputDirName, "index.html")); !os.IsNotExist(statErr) { + t.Fatalf("expected embedded output to stay unpublished after planning failure, stat err=%v", statErr) + } + if _, statErr := os.Stat(filepath.Join(appDir, appFileName)); !os.IsNotExist(statErr) { + t.Fatalf("expected generated app source to stay unpublished after planning failure, stat err=%v", statErr) + } } type appgenGoBlockAddon struct{} diff --git a/internal/appgen/files.go b/internal/appgen/files.go index 35fcb743..97f150c3 100644 --- a/internal/appgen/files.go +++ b/internal/appgen/files.go @@ -11,6 +11,11 @@ import ( "github.com/cssbruno/gowdk/internal/safeasset" ) +type plannedFile struct { + path string + contents []byte +} + func validateDirectories(outputDir, appDir string) error { info, err := os.Stat(outputDir) if err != nil { @@ -38,7 +43,21 @@ func isSameOrWithin(parent, child string) bool { } func copyOutputFiles(sourceRoot, targetRoot string) ([]string, error) { + files, planned, err := collectOutputFiles(sourceRoot, targetRoot) + if err != nil { + return nil, err + } + for _, file := range planned { + if err := writeFileIfChanged(file.path, file.contents); err != nil { + return nil, err + } + } + return files, nil +} + +func collectOutputFiles(sourceRoot, targetRoot string) ([]string, []plannedFile, error) { var files []string + var planned []plannedFile err := filepath.WalkDir(sourceRoot, func(sourcePath string, entry os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr @@ -56,7 +75,7 @@ func copyOutputFiles(sourceRoot, targetRoot string) ([]string, error) { if unsafeEmbeddedDirectory(rel) { return filepath.SkipDir } - return os.MkdirAll(targetPath, 0o755) + return nil } info, err := entry.Info() if err != nil { @@ -68,14 +87,16 @@ func copyOutputFiles(sourceRoot, targetRoot string) ([]string, error) { if !safeasset.EmbeddableGeneratedOutputFile(rel) { return nil } - if err := copyFile(sourcePath, targetPath); err != nil { + payload, err := os.ReadFile(sourcePath) + if err != nil { return err } + planned = append(planned, plannedFile{path: targetPath, contents: payload}) files = append(files, rel) return nil }) sort.Strings(files) - return files, err + return files, planned, err } func unsafeEmbeddedDirectory(rel string) bool { @@ -125,5 +146,31 @@ func writeFileIfChanged(filePath string, contents []byte) error { if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { return err } - return os.WriteFile(filePath, contents, 0o644) + temp, err := os.CreateTemp(filepath.Dir(filePath), "."+filepath.Base(filePath)+".tmp-*") + if err != nil { + return err + } + tempName := temp.Name() + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tempName) + } + }() + if _, err := temp.Write(contents); err != nil { + _ = temp.Close() + return err + } + if err := temp.Chmod(0o644); err != nil { + _ = temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + if err := os.Rename(tempName, filePath); err != nil { + return err + } + cleanup = false + return nil } diff --git a/internal/appgen/scripts.go b/internal/appgen/scripts.go index 06592c2a..5cb44449 100644 --- a/internal/appgen/scripts.go +++ b/internal/appgen/scripts.go @@ -23,29 +23,41 @@ type addonGoBlockTarget struct { } func writeInlineGoBlockFiles(appDir string, options Options) ([]string, error) { + files, planned, err := collectInlineGoBlockFiles(appDir, options) + if err != nil { + return nil, err + } + for _, file := range planned { + if err := writeFileIfChanged(file.path, file.contents); err != nil { + return nil, err + } + } + return files, nil +} + +func collectInlineGoBlockFiles(appDir string, options Options) ([]string, []plannedFile, error) { if options.IR == nil { - return nil, nil + return nil, nil, nil } groups := inlineGoBlockGroups(*options.IR) if len(groups) == 0 { - return nil, nil + return nil, nil, nil } var files []string + var planned []plannedFile for packageName, group := range groups { generated, err := goblockgen.Source(packageName, group.imports, group.goBlocks) if err != nil { - return nil, fmt.Errorf("generate inline go block package %s: %w", packageName, err) + return nil, nil, fmt.Errorf("generate inline go block package %s: %w", packageName, err) } if len(generated) == 0 { continue } relPath := goblockgen.GeneratedRelPath(packageName) - if err := writeFileIfChanged(filepath.Join(appDir, relPath), generated); err != nil { - return nil, err - } + planned = append(planned, plannedFile{path: filepath.Join(appDir, relPath), contents: generated}) files = append(files, relPath) } - return files, nil + return files, planned, nil } func inlineGoBlockGroups(ir gwdkir.Program) map[string]inlineGoBlockGroup { @@ -117,39 +129,51 @@ func mergeGoBlockImports(left []gwdkir.Import, right []gwdkir.Import) []gwdkir.I } func writeAddonGoBlockFiles(appDir string, options Options) ([]string, error) { + files, planned, err := collectAddonGoBlockFiles(appDir, options) + if err != nil { + return nil, err + } + for _, file := range planned { + if err := writeFileIfChanged(file.path, file.contents); err != nil { + return nil, err + } + } + return files, nil +} + +func collectAddonGoBlockFiles(appDir string, options Options) ([]string, []plannedFile, error) { if options.IR == nil { - return nil, nil + return nil, nil, nil } var files []string + var planned []plannedFile for _, target := range addonGoBlockTargets(*options.IR, options.Config) { consumer, ok := addonGoBlockConsumer(options.Config, target.target.Target) if !ok { - return nil, fmt.Errorf("go block target %s requires an enabled addon implementing gowdk.GoBlockConsumer", target.target.Target) + return nil, nil, fmt.Errorf("go block target %s requires an enabled addon implementing gowdk.GoBlockConsumer", target.target.Target) } generated, err := consumer.GeneratedGo(target.target, gowdk.GoBlockContext{Render: target.render}) if err != nil { - return nil, fmt.Errorf("generate addon go block target %s: %w", target.target.Target, err) + return nil, nil, fmt.Errorf("generate addon go block target %s: %w", target.target.Target, err) } for _, file := range generated { relPath, err := safeAddonGoBlockFilePath(file.Path) if err != nil { - return nil, err + return nil, nil, err } contents := []byte(file.Source) if strings.HasSuffix(relPath, ".go") { formatted, err := format.Source(contents) if err != nil { - return nil, fmt.Errorf("format addon go block file %s: %w", relPath, err) + return nil, nil, fmt.Errorf("format addon go block file %s: %w", relPath, err) } contents = formatted } - if err := writeFileIfChanged(filepath.Join(appDir, relPath), contents); err != nil { - return nil, err - } + planned = append(planned, plannedFile{path: filepath.Join(appDir, relPath), contents: contents}) files = append(files, relPath) } } - return files, nil + return files, planned, nil } func addonGoBlockConsumer(config gowdk.Config, target string) (gowdk.GoBlockConsumer, bool) { diff --git a/internal/buildgen/build.go b/internal/buildgen/build.go index f8c6ae33..0e902edb 100644 --- a/internal/buildgen/build.go +++ b/internal/buildgen/build.go @@ -78,6 +78,12 @@ func PlanBuildFromValidatedProgram(config gowdk.Config, validated compiler.Valid }, nil } +type plannedPublishedFile struct { + path string + contents []byte + countWrite bool +} + // BuildFromPlan emits SPA build artifacts from a normalized build plan. func BuildFromPlan(plan BuildPlan) (Result, error) { if !plan.valid || plan.reporter == nil { @@ -94,88 +100,81 @@ func BuildFromPlan(plan BuildPlan) (Result, error) { CSSArtifacts: make([]CSSArtifact, 0, len(planned.css)), AssetArtifacts: make([]AssetArtifact, 0, len(planned.assets)), } + files := make([]plannedPublishedFile, 0, len(planned.css)+len(planned.assets)+len(planned.pages)+6) for _, artifact := range planned.css { - wrote, err := writeFileIfChangedStatus(artifact.Path, artifact.contents) - if err != nil { - return Result{}, reporter.fail("write", err) - } - recordWriteStat(&result, wrote) - reporter.debug("write", "css_written", "CSS artifact written", BuildEvent{Path: eventPath(outputDir, artifact.Path)}) finalizeCSSArtifact(&artifact) result.CSSArtifacts = append(result.CSSArtifacts, artifact.CSSArtifact) + files = append(files, plannedPublishedFile{path: artifact.Path, contents: artifact.contents, countWrite: true}) + reporter.debug("write", "css_written", "CSS artifact written", BuildEvent{Path: eventPath(outputDir, artifact.Path)}) } for _, artifact := range planned.assets { - wrote, err := writeFileIfChangedStatus(artifact.Path, artifact.contents) - if err != nil { - return Result{}, reporter.fail("write", err) - } - recordWriteStat(&result, wrote) - reporter.debug("write", "asset_written", "runtime asset written", BuildEvent{Path: eventPath(outputDir, artifact.Path)}) finalizeAssetArtifact(&artifact) result.AssetArtifacts = append(result.AssetArtifacts, artifact.AssetArtifact) + files = append(files, plannedPublishedFile{path: artifact.Path, contents: artifact.contents, countWrite: true}) + reporter.debug("write", "asset_written", "runtime asset written", BuildEvent{Path: eventPath(outputDir, artifact.Path)}) } for _, artifact := range planned.pages { - wrote, err := writeFileIfChangedStatus(artifact.Path, artifact.contents) - if err != nil { - return Result{}, reporter.fail("write", err) - } - recordWriteStat(&result, wrote) + result.Artifacts = append(result.Artifacts, artifact.Artifact) + files = append(files, plannedPublishedFile{path: artifact.Path, contents: artifact.contents, countWrite: true}) reporter.debug("write", "page_written", "page artifact written", BuildEvent{ PageID: artifact.PageID, Route: artifact.Route, Path: eventPath(outputDir, artifact.Path), }) - result.Artifacts = append(result.Artifacts, artifact.Artifact) } endpoints := compiler.BuildRouteMetadataFromIR(config, ir).Endpoints - manifestPath, err := writeRouteManifest(outputDir, result.Artifacts, endpoints) + routeManifest, err := routeManifestPayload(outputDir, result.Artifacts, endpoints) if err != nil { return Result{}, reporter.fail("manifest", err) } - result.RouteManifestPath = manifestPath - reporter.info("manifest", "route_manifest_written", "route manifest written", BuildEvent{Path: eventPath(outputDir, manifestPath)}) + result.RouteManifestPath = filepath.Join(outputDir, routeManifestFile) + files = append(files, plannedPublishedFile{path: result.RouteManifestPath, contents: routeManifest}) + reporter.info("manifest", "route_manifest_written", "route manifest written", BuildEvent{Path: eventPath(outputDir, result.RouteManifestPath)}) seoPlan, err := planSEOArtifacts(config, ir, result.Artifacts) if err != nil { return Result{}, reporter.fail("seo", err) } reportSEOExclusions(reporter, seoPlan.Exclusions) - sitemapPath, robotsPath, sitemapWrote, robotsWrote, err := writeSEOArtifacts(outputDir, seoPlan) - if err != nil { - return Result{}, reporter.fail("seo", err) - } - if sitemapPath != "" { - recordWriteStat(&result, sitemapWrote) - result.SitemapPath = sitemapPath + if seoPlan.Enabled { + result.SitemapPath = filepath.Join(outputDir, sitemapFile) + result.RobotsPath = filepath.Join(outputDir, robotsFile) + files = append(files, + plannedPublishedFile{path: result.SitemapPath, contents: seoPlan.Sitemap, countWrite: true}, + plannedPublishedFile{path: result.RobotsPath, contents: seoPlan.Robots, countWrite: true}, + ) reporter.info("seo", "sitemap_written", "sitemap written", BuildEvent{ - Path: eventPath(outputDir, sitemapPath), + Path: eventPath(outputDir, result.SitemapPath), Data: map[string]string{"urls": fmt.Sprint(len(seoPlan.URLs))}, }) + reporter.info("seo", "robots_written", "robots.txt written", BuildEvent{Path: eventPath(outputDir, result.RobotsPath)}) } - if robotsPath != "" { - recordWriteStat(&result, robotsWrote) - result.RobotsPath = robotsPath - reporter.info("seo", "robots_written", "robots.txt written", BuildEvent{Path: eventPath(outputDir, robotsPath)}) - } - assetManifestPath, err := writeAssetManifest(outputDir, result.Artifacts, result.CSSArtifacts, result.AssetArtifacts) + assetManifest, err := assetManifestPayload(outputDir, result.Artifacts, result.CSSArtifacts, result.AssetArtifacts) if err != nil { return Result{}, reporter.fail("manifest", err) } - result.AssetManifestPath = assetManifestPath - reporter.info("manifest", "asset_manifest_written", "asset manifest written", BuildEvent{Path: eventPath(outputDir, assetManifestPath)}) + result.AssetManifestPath = filepath.Join(outputDir, assetManifestFile) + files = append(files, plannedPublishedFile{path: result.AssetManifestPath, contents: assetManifest}) + reporter.info("manifest", "asset_manifest_written", "asset manifest written", BuildEvent{Path: eventPath(outputDir, result.AssetManifestPath)}) reportCachePolicies(reporter, result.Artifacts, result.CSSArtifacts, result.AssetArtifacts) reportAssetSizes(reporter, outputDir, result.AssetArtifacts) - openAPIPath, err := writeOpenAPI(outputDir, config, ir) + openAPI, err := openAPIPayload(config, ir) if err != nil { return Result{}, reporter.fail("report", err) } - result.OpenAPIPath = openAPIPath - reporter.info("report", "openapi_written", "OpenAPI report written", BuildEvent{Path: eventPath(outputDir, openAPIPath)}) - securityManifestPath, err := writeSecurityManifest(outputDir, config, ir) + result.OpenAPIPath = filepath.Join(outputDir, openAPIFile) + files = append(files, plannedPublishedFile{path: result.OpenAPIPath, contents: openAPI}) + reporter.info("report", "openapi_written", "OpenAPI report written", BuildEvent{Path: eventPath(outputDir, result.OpenAPIPath)}) + securityManifest, err := securityManifestPayload(config, ir) if err != nil { return Result{}, reporter.fail("manifest", err) } - result.SecurityManifestPath = securityManifestPath - reporter.info("manifest", "security_manifest_written", "security manifest written", BuildEvent{Path: eventPath(outputDir, securityManifestPath)}) + securityPath, err := securityManifestPath(outputDir) + if err != nil { + return Result{}, reporter.fail("manifest", err) + } + result.SecurityManifestPath = securityPath + files = append(files, plannedPublishedFile{path: result.SecurityManifestPath, contents: securityManifest}) + reporter.info("manifest", "security_manifest_written", "security manifest written", BuildEvent{Path: eventPath(outputDir, result.SecurityManifestPath)}) reporter.info("complete", "build_complete", "SPA build completed", BuildEvent{ Data: map[string]string{ "pages": fmt.Sprint(len(result.Artifacts)), @@ -184,11 +183,24 @@ func BuildFromPlan(plan BuildPlan) (Result, error) { }, }) result.Report = reporter.result() - buildReportPath, err := writeBuildReport(outputDir, result.Report) + buildReport, err := buildReportPayload(result.Report) if err != nil { return Result{}, reporter.fail("report", err) } - result.BuildReportPath = buildReportPath + result.BuildReportPath = buildReportPath(outputDir) + files = append(files, plannedPublishedFile{path: result.BuildReportPath, contents: buildReport}) + for _, file := range files { + wrote, err := writeFileIfChangedStatus(file.path, file.contents) + if err != nil { + return Result{}, reporter.fail("write", err) + } + if file.countWrite { + recordWriteStat(&result, wrote) + } + } + if err := removeServedSecurityManifest(outputDir); err != nil { + return Result{}, reporter.fail("cleanup", err) + } return result, nil } diff --git a/internal/buildgen/ir_test.go b/internal/buildgen/ir_test.go index a43cabab..6b83dd33 100644 --- a/internal/buildgen/ir_test.go +++ b/internal/buildgen/ir_test.go @@ -121,6 +121,37 @@ func TestBuildFromPlanRejectsZeroValue(t *testing.T) { } } +func TestBuildFromPlanDoesNotPublishBeforeManifestPlanningSucceeds(t *testing.T) { + outputDir := t.TempDir() + plannedCSSPath := filepath.Join(outputDir, "assets", "site.css") + plannedPagePath := filepath.Join(outputDir, "index.html") + plan := BuildPlan{ + reporter: newBuildReporter("build", outputDir), + outputDir: outputDir, + valid: true, + planned: buildPlan{ + css: []plannedCSSArtifact{{ + CSSArtifact: CSSArtifact{Path: plannedCSSPath}, + contents: []byte("body{color:red}"), + }}, + pages: []plannedArtifact{{ + Artifact: Artifact{PageID: "home", Route: "/", Path: filepath.Join(filepath.Dir(outputDir), "outside.html")}, + contents: []byte("
new
"), + }}, + }, + } + + _, err := BuildFromPlan(plan) + if err == nil || !strings.Contains(err.Error(), "must stay inside output directory") { + t.Fatalf("expected manifest planning error, got %v", err) + } + for _, path := range []string{plannedCSSPath, plannedPagePath} { + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("expected %s to stay unpublished, stat err=%v", path, statErr) + } + } +} + func TestBuildMemoryFromIRCollectsArtifacts(t *testing.T) { config := gowdk.Config{} app := gwdkanalysis.Sources{Pages: []gwdkir.Page{{ diff --git a/internal/buildgen/manifests.go b/internal/buildgen/manifests.go index d2cef5a3..829b4f5f 100644 --- a/internal/buildgen/manifests.go +++ b/internal/buildgen/manifests.go @@ -327,7 +327,33 @@ func writeFileIfChangedStatus(filePath string, contents []byte) (bool, error) { if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { return false, err } - return true, os.WriteFile(filePath, contents, 0o644) + temp, err := os.CreateTemp(filepath.Dir(filePath), "."+filepath.Base(filePath)+".tmp-*") + if err != nil { + return false, err + } + tempName := temp.Name() + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tempName) + } + }() + if _, err := temp.Write(contents); err != nil { + _ = temp.Close() + return false, err + } + if err := temp.Chmod(0o644); err != nil { + _ = temp.Close() + return false, err + } + if err := temp.Close(); err != nil { + return false, err + } + if err := os.Rename(tempName, filePath); err != nil { + return false, err + } + cleanup = false + return true, nil } func contentHash(contents []byte) string {