From 27e4b3ca47ebbd9b2992eec6772617f1f5104799 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Mon, 10 Aug 2026 12:50:41 -0400 Subject: [PATCH] Add source-only forget command --- README.md | 3 + docs/reconciliation.md | 7 + internal/application/forget/service.go | 212 ++++++++++++++++++++ internal/application/forget/service_test.go | 173 ++++++++++++++++ internal/bootstrap/applications.go | 15 ++ internal/bootstrap/build.go | 1 + internal/cli/forget.go | 54 +++++ internal/cli/forget_test.go | 40 ++++ internal/cli/root.go | 2 + internal/filesystem/remove.go | 57 ++++++ internal/repository/sources.go | 77 +++++++ 11 files changed, 641 insertions(+) create mode 100644 internal/application/forget/service.go create mode 100644 internal/application/forget/service_test.go create mode 100644 internal/cli/forget.go create mode 100644 internal/cli/forget_test.go create mode 100644 internal/filesystem/remove.go create mode 100644 internal/repository/sources.go diff --git a/README.md b/README.md index 7019d57..543c074 100644 --- a/README.md +++ b/README.md @@ -68,11 +68,14 @@ See [repository layout](docs/repository-layout.md) for the full grammar. | `cattery diff [GROUP ...]` | show safe content differences | | `cattery apply [GROUP ...]` | reconcile targets with the repository | | `cattery add [OPTIONS] TARGET ...` | adopt target files or directories into the repository | +| `cattery forget DIRECTORY` | stop managing a directory while leaving its target files in place | | `cattery version` | print version, commit, and build metadata | Global options are `--repo PATH` and `--verbose`. Apply accepts `--dry-run`, `--non-interactive`, and `--no-hooks`. Add accepts `--group NAME`, `--platform linux|darwin`, `--secret`, and `--dry-run`. +Forget accepts `--dry-run` to preview every source it would remove and requires +`--yes` before it changes the repository. When `cattery apply` needs a decision it prompts for overwrite, skip, abort, or diff, and collects every decision before changing anything. Piped or diff --git a/docs/reconciliation.md b/docs/reconciliation.md index c25b2ab..2d41066 100644 --- a/docs/reconciliation.md +++ b/docs/reconciliation.md @@ -102,6 +102,13 @@ is removed, the target is left in place and only its tracking row is retired. ## Source removal +`cattery forget DIRECTORY --yes` removes every base and platform-specific +repository source that manages the named subtree, then retires its active file +rows. It never deletes or rewrites anything under `$HOME`. Use `--dry-run` to +inspect the exact sources first. Forget refuses a subtree involved in an alias, +so aliases must be removed deliberately before the canonical files are +forgotten. + When a selected state row's target has no producer anywhere in the current plan, Cattery retires only the tracking row: diff --git a/internal/application/forget/service.go b/internal/application/forget/service.go new file mode 100644 index 0000000..a0ac638 --- /dev/null +++ b/internal/application/forget/service.go @@ -0,0 +1,212 @@ +// Package forget implements `cattery forget`: source-only removal of a +// managed HOME subtree. It never removes the user's target files. +package forget + +import ( + "context" + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/alyraffauf/cattery/internal/application/evaluation" + applicationrepository "github.com/alyraffauf/cattery/internal/application/repository" + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/alyraffauf/cattery/internal/filesystem" + "github.com/alyraffauf/cattery/internal/pathsafe" + "github.com/alyraffauf/cattery/internal/repository" + "github.com/alyraffauf/cattery/internal/selection" + "github.com/alyraffauf/cattery/internal/state" +) + +type Dependencies struct { + RepositorySource applicationrepository.RepositorySource + Compiler applicationrepository.Compiler + State evaluation.StateReader + Retirements RetirementStore + Remover SourceRemover +} + +type RetirementStore interface { + RetireFileBaseline(root, home, target string) (state.FileBaseline, error) +} + +type SourceRemover interface { + RemoveResult(context.Context, filesystem.Precondition) (filesystem.RemoveResult, error) +} + +type Request struct { + Repository RepositoryInput + Directory string + DryRun bool + Yes bool +} + +// RepositoryInput carries raw repository-selection fields from the CLI. +type RepositoryInput = applicationrepository.RepositoryInput + +type Item struct { + Target string + Source string + Status string +} + +type Result struct { + Items []Item +} + +type Service struct{ deps Dependencies } + +func NewService(dependencies Dependencies) *Service { return &Service{deps: dependencies} } + +// Forget removes matching sources and retires their active state rows. The +// target subtree is used only as an address; it is never read or mutated. +func (service *Service) Forget(ctx context.Context, request Request) (Result, error) { + identity, err := service.resolve(request.Repository) + if err != nil { + return Result{}, err + } + directory, err := resolveDirectory(request.Repository.WorkingDir, identity.Home, request.Directory) + if err != nil { + return Result{}, err + } + if err := service.rejectAliases(identity, directory); err != nil { + return Result{}, err + } + items, err := service.plan(identity, directory) + if err != nil { + return Result{}, err + } + result := Result{Items: records(items, "planned")} + if request.DryRun { + return result, nil + } + if !request.Yes { + return result, failure.New(failure.InvalidInput, "forget: pass --yes to remove repository sources", nil) + } + return service.execute(ctx, identity, items) +} + +func (service *Service) resolve(input applicationrepository.RepositoryInput) (applicationrepository.RepositoryIdentity, error) { + identity, err := service.deps.RepositorySource.Resolve(selection.RepositoryRequest{ + RawExplicit: input.RawExplicit, ExplicitSet: input.ExplicitSet, RawEnv: input.RawEnv, + EnvSet: input.EnvSet, WorkingDir: input.WorkingDir, + }) + if err != nil { + return applicationrepository.RepositoryIdentity{}, failure.New(failure.InvalidInput, "forget: resolve repository", err) + } + return identity, nil +} + +func resolveDirectory(workingDirectory, home, argument string) (string, error) { + if argument == "" { + return "", failure.New(failure.InvalidInput, "forget: empty directory", nil) + } + path := argument + if !filepath.IsAbs(path) { + path = filepath.Join(workingDirectory, path) + } + canonical, err := pathsafe.CanonicalRoot(path) + if err != nil { + return "", failure.New(failure.InvalidInput, "forget: resolve directory "+argument, err) + } + if canonical == home || !pathsafe.Contains(home, canonical) { + return "", failure.New(failure.InvalidInput, "forget: directory must be strictly beneath $HOME", nil) + } + relative, err := filepath.Rel(home, canonical) + if err != nil { + return "", failure.New(failure.InvalidInput, "forget: relative directory", err) + } + return filepath.ToSlash(relative), nil +} + +func (service *Service) rejectAliases(identity applicationrepository.RepositoryIdentity, directory string) error { + for _, platform := range []deployment.Layer{deployment.LayerLinux, deployment.LayerDarwin} { + plan, err := service.deps.Compiler.Compile(repository.CompileInput{Platform: platform, RepositoryRoot: identity.Root, HomeRoot: identity.Home}) + if err != nil { + return failure.New(failure.InvalidInput, "forget: compile plan", err) + } + for _, alias := range plan.Aliases() { + if within(directory, alias.AliasRelativePath) || within(directory, alias.CanonicalTargetRelativePath) { + return failure.New(failure.InvalidInput, "forget: remove aliases before forgetting "+directory, nil) + } + } + } + return nil +} + +type plannedItem struct { + target string + source repository.Source +} + +func (service *Service) plan(identity applicationrepository.RepositoryIdentity, directory string) ([]plannedItem, error) { + sources, err := repository.Sources(identity.Root) + if err != nil { + return nil, failure.New(failure.InvalidInput, "forget: scan repository", err) + } + items := make([]plannedItem, 0) + for _, source := range sources { + if within(directory, source.TargetRelativePath) { + items = append(items, plannedItem{target: source.TargetRelativePath, source: source}) + } + } + sort.Slice(items, func(left, right int) bool { + return items[left].source.Candidate.SourceRepoPath < items[right].source.Candidate.SourceRepoPath + }) + return items, nil +} + +func within(directory, path string) bool { + return path == directory || strings.HasPrefix(path, directory+"/") +} + +func records(items []plannedItem, status string) []Item { + records := make([]Item, 0, len(items)) + for _, item := range items { + records = append(records, Item{Target: item.target, Source: item.source.Candidate.SourceRepoPath, Status: status}) + } + return records +} + +func (service *Service) execute(ctx context.Context, identity applicationrepository.RepositoryIdentity, items []plannedItem) (Result, error) { + result := Result{Items: make([]Item, 0, len(items))} + active, err := service.deps.State.FileBaselines(identity.Root, identity.Home) + if err != nil { + return result, fmt.Errorf("forget: read baselines: %w", err) + } + activeTargets := activeTargetSet(active) + retiredTargets := make(map[string]bool, len(activeTargets)) + for _, item := range items { + if err := ctx.Err(); err != nil { + return result, err + } + precondition, err := filesystem.Freeze(filesystem.Destination{Root: identity.Root, Relative: item.source.Candidate.SourceRepoPath}) + if err != nil { + return result, fmt.Errorf("forget: freeze source %s: %w", item.source.Candidate.SourceRepoPath, err) + } + removed, err := service.deps.Remover.RemoveResult(ctx, precondition) + if err != nil || !removed.Removed || !removed.DirectorySynced { + return result, fmt.Errorf("forget: remove source %s: %w", item.source.Candidate.SourceRepoPath, err) + } + if activeTargets[item.target] && !retiredTargets[item.target] { + if _, err := service.deps.Retirements.RetireFileBaseline(identity.Root, identity.Home, item.target); err != nil { + return result, fmt.Errorf("forget: retire %s: %w", item.target, err) + } + retiredTargets[item.target] = true + } + result.Items = append(result.Items, Item{Target: item.target, Source: item.source.Candidate.SourceRepoPath, Status: "forgotten"}) + } + return result, nil +} + +func activeTargetSet(baselines []state.FileBaseline) map[string]bool { + targets := make(map[string]bool, len(baselines)) + for _, baseline := range baselines { + if baseline.Status == state.StatusActive { + targets[baseline.TargetPath] = true + } + } + return targets +} diff --git a/internal/application/forget/service_test.go b/internal/application/forget/service_test.go new file mode 100644 index 0000000..7af8857 --- /dev/null +++ b/internal/application/forget/service_test.go @@ -0,0 +1,173 @@ +package forget + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/alyraffauf/cattery/internal/application/repository" + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/filesystem" + backend "github.com/alyraffauf/cattery/internal/repository" + "github.com/alyraffauf/cattery/internal/selection" + "github.com/alyraffauf/cattery/internal/state" + testdb "github.com/alyraffauf/cattery/internal/testfixture/database" + testfs "github.com/alyraffauf/cattery/internal/testfixture/filesystem" +) + +func TestForgetRemovesSourcesAndRetiresStateWithoutDeletingTargets(t *testing.T) { + stage := newStage(t, deployment.Plan{}) + stage.writeRepository(t, "nvim/.config/nvim/init.lua", []byte("base")) + stage.writeRepository(t, "nvim/_linux/.config/nvim/init.lua", []byte("linux")) + stage.writeHome(t, ".config/nvim/init.lua", []byte("user file")) + stage.activate(t, ".config/nvim/init.lua") + + result, err := stage.service.Forget(context.Background(), stage.request(".config/nvim", false, true)) + if err != nil { + t.Fatalf("Forget: %v", err) + } + if len(result.Items) != 2 { + t.Fatalf("items = %#v, want both base and platform sources", result.Items) + } + stage.requireMissing(t, "nvim/.config/nvim/init.lua") + stage.requireMissing(t, "nvim/_linux/.config/nvim/init.lua") + stage.requireHome(t, ".config/nvim/init.lua", "user file") + stage.requireRetired(t, ".config/nvim/init.lua") +} + +func TestForgetDryRunAndConfirmationDoNotMutate(t *testing.T) { + stage := newStage(t, deployment.Plan{}) + stage.writeRepository(t, "shell/.config/shell/env", []byte("source")) + stage.writeHome(t, ".config/shell/env", []byte("target")) + + result, err := stage.service.Forget(context.Background(), stage.request(".config/shell", true, false)) + if err != nil || len(result.Items) != 1 || result.Items[0].Status != "planned" { + t.Fatalf("dry run = %#v, %v", result, err) + } + stage.requireSource(t, "shell/.config/shell/env") + + _, err = stage.service.Forget(context.Background(), stage.request(".config/shell", false, false)) + if err == nil { + t.Fatal("forget without --yes succeeded") + } + stage.requireSource(t, "shell/.config/shell/env") +} + +func TestForgetRejectsAliasedDirectories(t *testing.T) { + alias, err := deployment.NewAlias(deployment.Alias{Platform: "linux", AliasRelativePath: ".config/nvim/init.lua", CanonicalTargetRelativePath: ".config/editor/init.lua"}) + if err != nil { + t.Fatal(err) + } + plan, err := deployment.NewPlan(deployment.PlanInput{RepositoryRoot: "repo", Platform: "linux", Aliases: []deployment.Alias{alias}}) + if err != nil { + t.Fatal(err) + } + stage := newStage(t, plan) + stage.writeRepository(t, "editor/.config/editor/init.lua", []byte("source")) + + _, err = stage.service.Forget(context.Background(), stage.request(".config/editor", false, true)) + if err == nil { + t.Fatal("forget accepted an aliased directory") + } + stage.requireSource(t, "editor/.config/editor/init.lua") +} + +type stage struct { + service *Service + repo string + home string + store *state.Store +} + +func newStage(t *testing.T, plan deployment.Plan) stage { + t.Helper() + fixture := testdb.New(t) + repoRoot := t.TempDir() + compiler := compiler{plan: plan, repo: repoRoot} + service := NewService(Dependencies{ + RepositorySource: source{identity: repository.RepositoryIdentity{Root: repoRoot, Home: fixture.Home}}, + Compiler: compiler, + State: fixture.Store, + Retirements: fixture.Store, + Remover: filesystem.NewReplacer(), + }) + return stage{service: service, repo: repoRoot, home: fixture.Home, store: fixture.Store} +} + +func (stage stage) request(directory string, dryRun, yes bool) Request { + return Request{Repository: RepositoryInput{WorkingDir: stage.home}, Directory: directory, DryRun: dryRun, Yes: yes} +} + +func (stage stage) writeRepository(t *testing.T, relative string, content []byte) { + t.Helper() + if err := testfs.New(stage.repo).File(relative, content, 0o600).Materialize(); err != nil { + t.Fatal(err) + } +} + +func (stage stage) writeHome(t *testing.T, relative string, content []byte) { + t.Helper() + if err := testfs.New(stage.home).File(relative, content, 0o600).Materialize(); err != nil { + t.Fatal(err) + } +} + +func (stage stage) activate(t *testing.T, target string) { + t.Helper() + baseline := state.FileBaseline{TargetPath: target, SourcePath: "source", SourceKind: deployment.FileOrdinary, Layer: deployment.LayerBase, + BaselineContentHash: deployment.Ordinary([]byte("content")), BaselineSourceHash: deployment.Ordinary([]byte("source"))} + if _, err := stage.store.UpsertFileBaseline(stage.repo, stage.home, baseline); err != nil { + t.Fatal(err) + } +} + +func (stage stage) requireMissing(t *testing.T, relative string) { + t.Helper() + _, err := os.Lstat(filepath.Join(stage.repo, relative)) + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("source %s still exists: %v", relative, err) + } +} + +func (stage stage) requireSource(t *testing.T, relative string) { + t.Helper() + if _, err := os.Lstat(filepath.Join(stage.repo, relative)); err != nil { + t.Fatalf("source %s: %v", relative, err) + } +} + +func (stage stage) requireHome(t *testing.T, relative, want string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(stage.home, relative)) + if err != nil || string(content) != want { + t.Fatalf("target %s = %q, %v; want %q", relative, content, err, want) + } +} + +func (stage stage) requireRetired(t *testing.T, target string) { + t.Helper() + baselines, err := stage.store.FileBaselines(stage.repo, stage.home) + if err != nil || len(baselines) != 1 || baselines[0].Status != state.StatusRetired { + t.Fatalf("baselines = %#v, %v; want one retired row", baselines, err) + } +} + +type source struct{ identity repository.RepositoryIdentity } + +func (source source) Resolve(selection.RepositoryRequest) (repository.RepositoryIdentity, error) { + return source.identity, nil +} + +type compiler struct { + plan deployment.Plan + repo string +} + +func (compiler compiler) Compile(input backend.CompileInput) (deployment.Plan, error) { + if len(compiler.plan.Aliases()) == 0 { + return deployment.NewPlan(deployment.PlanInput{RepositoryRoot: compiler.repo, Platform: string(input.Platform)}) + } + return compiler.plan, nil +} diff --git a/internal/bootstrap/applications.go b/internal/bootstrap/applications.go index 8d703d6..408de07 100644 --- a/internal/bootstrap/applications.go +++ b/internal/bootstrap/applications.go @@ -5,6 +5,7 @@ import ( "github.com/alyraffauf/cattery/internal/application/add" "github.com/alyraffauf/cattery/internal/application/apply" + "github.com/alyraffauf/cattery/internal/application/forget" "github.com/alyraffauf/cattery/internal/application/initialize" "github.com/alyraffauf/cattery/internal/application/inspect" applicationrepository "github.com/alyraffauf/cattery/internal/application/repository" @@ -22,6 +23,7 @@ type Applications struct { Inspect *inspect.Service Apply *apply.Service Add *add.Service + Forget *forget.Service } // ApplicationsInput carries the adapters, home, platform, protected @@ -81,9 +83,22 @@ func BuildApplications(input ApplicationsInput) Applications { Inspect: buildInspect(input, shared), Apply: buildApply(input, shared), Add: buildAdd(input, shared), + Forget: buildForget(input, shared), } } +// buildForget wires source removal and baseline retirement without granting +// the service any access to HOME targets. +func buildForget(input ApplicationsInput, shared shared) *forget.Service { + return forget.NewService(forget.Dependencies{ + RepositorySource: repositorySourceOf(shared.resolver, repositoryIdentity), + Compiler: shared.compiler, + State: shared.state, + Retirements: shared.retirements, + Remover: input.Adapters.Replacer, + }) +} + // buildInitialize wires the repository initialization service. func buildInitialize(input ApplicationsInput, shared shared) *initialize.Service { return initialize.NewService(initialize.Dependencies{ diff --git a/internal/bootstrap/build.go b/internal/bootstrap/build.go index d9e9d25..8e2029d 100644 --- a/internal/bootstrap/build.go +++ b/internal/bootstrap/build.go @@ -55,6 +55,7 @@ func Build(input BuildInput) *cli.Application { Status: services.Inspect, Diff: services.Inspect, Add: services.Add, + Forget: services.Forget, Apply: services.Apply, }, runtimeValues) } diff --git a/internal/cli/forget.go b/internal/cli/forget.go new file mode 100644 index 0000000..6d2f075 --- /dev/null +++ b/internal/cli/forget.go @@ -0,0 +1,54 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/alyraffauf/cattery/internal/application/forget" + "github.com/spf13/cobra" +) + +// ForgetService is the one-method role the forget adapter calls. +type ForgetService interface { + Forget(context.Context, forget.Request) (forget.Result, error) +} + +func newForgetCommand(service ForgetService, runtime Runtime, options *Options) *cobra.Command { + command := &cobra.Command{ + Use: "forget DIRECTORY", + Short: "Stop managing a directory without deleting its files", + Args: cobra.ExactArgs(1), + RunE: func(command *cobra.Command, arguments []string) error { + dryRun, _ := command.Flags().GetBool("dry-run") + yes, _ := command.Flags().GetBool("yes") + result, err := service.Forget(command.Context(), forget.Request{ + Repository: forgetRepository(*options, command, runtime), Directory: arguments[0], DryRun: dryRun, Yes: yes, + }) + if renderErr := renderForget(runtime.Stdout(), result); renderErr != nil { + return renderErr + } + return err + }, + } + command.Flags().Bool("dry-run", false, "show the sources that would be removed") + command.Flags().Bool("yes", false, "remove repository sources without a prompt") + return command +} + +func forgetRepository(options Options, command *cobra.Command, runtime Runtime) forget.RepositoryInput { + options.RepositorySet = options.RepositorySet || command.Flags().Changed("repo") + environment, environmentSet := runtime.EnvValue("CATTERY_REPO") + return forget.RepositoryInput{ + RawExplicit: options.Repository, ExplicitSet: options.RepositorySet, + RawEnv: environment, EnvSet: environmentSet, WorkingDir: runtime.WorkingDir(), + } +} + +func renderForget(writer interface{ Write([]byte) (int, error) }, result forget.Result) error { + for _, item := range result.Items { + if _, err := fmt.Fprintf(writer, "$HOME/%s %s %s\n", displayPath(item.Target), item.Status, displayPath(item.Source)); err != nil { + return err + } + } + return nil +} diff --git a/internal/cli/forget_test.go b/internal/cli/forget_test.go new file mode 100644 index 0000000..500ad56 --- /dev/null +++ b/internal/cli/forget_test.go @@ -0,0 +1,40 @@ +package cli + +import ( + "bytes" + "context" + "testing" + + "github.com/alyraffauf/cattery/internal/application/forget" +) + +func TestForgetCommandMapsFlagsAndRendersPlan(t *testing.T) { + service := &forgetServiceFake{result: forget.Result{Items: []forget.Item{{Target: ".config/nvim/init.lua", Source: "nvim/.config/nvim/init.lua", Status: "planned"}}}} + stdout := &bytes.Buffer{} + runtime := NewRuntime(RuntimeInput{Streams: Streams{Stdout: stdout}, WorkingDir: "/work", Environment: []string{"CATTERY_REPO=environment"}}) + options := Options{} + command := newForgetCommand(service, runtime, &options) + bindSharedFlags(command, &options) + command.SetArgs([]string{"--dry-run", ".config/nvim"}) + if err := command.Execute(); err != nil { + t.Fatalf("run: %v", err) + } + request := service.requests[0] + if request.Directory != ".config/nvim" || !request.DryRun || request.Yes { + t.Fatalf("request = %#v", request) + } + if got := stdout.String(); got != "$HOME/.config/nvim/init.lua planned nvim/.config/nvim/init.lua\n" { + t.Fatalf("stdout = %q", got) + } +} + +type forgetServiceFake struct { + requests []forget.Request + result forget.Result + err error +} + +func (fake *forgetServiceFake) Forget(ctx context.Context, request forget.Request) (forget.Result, error) { + fake.requests = append(fake.requests, request) + return fake.result, fake.err +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 4bb70f4..1d7fe9a 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -15,6 +15,7 @@ type Dependencies struct { Status StatusService Diff DiffService Add AddService + Forget ForgetService Apply ApplyService } @@ -55,6 +56,7 @@ func NewApplication(dependencies Dependencies, runtime Runtime) *Application { newStatusCommand(dependencies.Status, runtime, options), newDiffCommand(dependencies.Diff, runtime, options), newAddCommand(dependencies.Add, runtime, options), + newForgetCommand(dependencies.Forget, runtime, options), newApplyCommand(dependencies.Apply, runtime, options), ) return &Application{root: root} diff --git a/internal/filesystem/remove.go b/internal/filesystem/remove.go new file mode 100644 index 0000000..26e4d65 --- /dev/null +++ b/internal/filesystem/remove.go @@ -0,0 +1,57 @@ +package filesystem + +import ( + "context" + "fmt" + "path/filepath" +) + +// RemoveResult records whether the source was removed and its parent directory +// was made durable. +type RemoveResult struct { + Removed bool + DirectorySynced bool +} + +// RemoveResult removes a frozen regular file without following a substituted +// path. It first renames the file to a same-directory tombstone, so a failure +// before the rename leaves the managed source intact. +func (r *Replacer) RemoveResult(ctx context.Context, precondition Precondition) (RemoveResult, error) { + if err := ctx.Err(); err != nil { + return RemoveResult{}, err + } + if precondition.Target().Kind() != KindFile { + return RemoveResult{}, fmt.Errorf("filesystem: removal target is not a regular file") + } + if err := validatePublication(precondition); err != nil { + return RemoveResult{}, err + } + destination := precondition.Destination() + directory := filepath.Dir(targetPath(destination)) + tombstone, handle, err := r.create(directory, ".removal-*") + if err != nil { + return RemoveResult{}, err + } + if err := CommitFile(ctx, handle); err != nil { + r.discard(&temporaryFile{temp: tombstone, handle: handle}) + return RemoveResult{}, err + } + if err := r.remove(tombstone.Name()); err != nil { + return RemoveResult{}, err + } + if err := r.rename(targetPath(destination), tombstone.Name()); err != nil { + return RemoveResult{}, err + } + result := RemoveResult{Removed: true} + if _, err := r.syncer.SyncResult(ctx, directory); err != nil { + return result, err + } + if err := r.remove(tombstone.Name()); err != nil { + return result, err + } + if _, err := r.syncer.SyncResult(ctx, directory); err != nil { + return result, err + } + result.DirectorySynced = true + return result, nil +} diff --git a/internal/repository/sources.go b/internal/repository/sources.go new file mode 100644 index 0000000..a72e1c3 --- /dev/null +++ b/internal/repository/sources.go @@ -0,0 +1,77 @@ +package repository + +import ( + "sort" + + "github.com/alyraffauf/cattery/internal/deployment" +) + +// Source is one repository file and the HOME-relative target it manages. +// It includes base and both platform-layer files, even when an override makes +// a source inactive on the current platform. +type Source struct { + Candidate Candidate + TargetRelativePath string +} + +// Sources returns every deployable repository source in deterministic order. +func Sources(root string) ([]Source, error) { + base, err := Scan(root) + if err != nil { + return nil, err + } + ignores, err := loadIgnoreMatcher(root) + if err != nil { + return nil, err + } + sources, err := baseSources(base.Files) + if err != nil { + return nil, err + } + for _, layer := range []deployment.Layer{deployment.LayerLinux, deployment.LayerDarwin} { + layerSources, err := platformSources(root, base.Groups, layer, ignores) + if err != nil { + return nil, err + } + sources = append(sources, layerSources...) + } + sort.Slice(sources, func(left, right int) bool { + return sources[left].Candidate.SourceRepoPath < sources[right].Candidate.SourceRepoPath + }) + return sources, nil +} + +func baseSources(candidates []Candidate) ([]Source, error) { + sources := make([]Source, 0, len(candidates)) + for _, candidate := range candidates { + target, err := baseTarget(candidate) + if err != nil { + return nil, err + } + sources = append(sources, Source{Candidate: candidate, TargetRelativePath: target}) + } + return sources, nil +} + +func platformSources(root string, groups []string, layer deployment.Layer, ignores ignoreMatcher) ([]Source, error) { + scopes := append([]deployment.Scope{deployment.NewScope("")}, scopesFor(groups)...) + var sources []Source + for _, scope := range scopes { + view, err := scanLayerTree(root, scope, layer, ignores) + if err != nil { + return nil, err + } + for target, candidate := range view.files { + sources = append(sources, Source{Candidate: candidate, TargetRelativePath: target}) + } + } + return sources, nil +} + +func scopesFor(groups []string) []deployment.Scope { + scopes := make([]deployment.Scope, 0, len(groups)) + for _, group := range groups { + scopes = append(scopes, deployment.NewScope(group)) + } + return scopes +} -- 2.51.2