From 4da1574949bf7e343df043c6b8a535db858d5dd6 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Mon, 23 Mar 2026 21:49:57 -0700 Subject: [PATCH] Step 7: Add remove command to stop tracking files. Implements Garden.Remove() which unregisters paths from the manifest, plus unit tests and the CLI wiring via cobra. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/sgard/remove.go | 31 +++++++++++++++++++++ garden/remove.go | 38 +++++++++++++++++++++++++ garden/remove_test.go | 65 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 cmd/sgard/remove.go create mode 100644 garden/remove.go create mode 100644 garden/remove_test.go diff --git a/cmd/sgard/remove.go b/cmd/sgard/remove.go new file mode 100644 index 0000000..828be41 --- /dev/null +++ b/cmd/sgard/remove.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + + "github.com/kisom/sgard/garden" + "github.com/spf13/cobra" +) + +var removeCmd = &cobra.Command{ + Use: "remove ...", + Short: "Stop tracking files", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + g, err := garden.Open(repoFlag) + if err != nil { + return err + } + + if err := g.Remove(args); err != nil { + return err + } + + fmt.Printf("Removed %d path(s)\n", len(args)) + return nil + }, +} + +func init() { + rootCmd.AddCommand(removeCmd) +} diff --git a/garden/remove.go b/garden/remove.go new file mode 100644 index 0000000..89e93bb --- /dev/null +++ b/garden/remove.go @@ -0,0 +1,38 @@ +package garden + +import ( + "fmt" + "path/filepath" +) + +// Remove stops tracking the given paths. Each path is resolved to absolute +// form, converted to a tilde path, and removed from the manifest. An error +// is returned if any path is not currently tracked. +func (g *Garden) Remove(paths []string) error { + for _, p := range paths { + abs, err := filepath.Abs(p) + if err != nil { + return fmt.Errorf("resolving path %s: %w", p, err) + } + + tilded := toTildePath(abs) + + if g.findEntry(tilded) == nil { + return fmt.Errorf("not tracking %s", tilded) + } + + filtered := g.manifest.Files[:0] + for _, e := range g.manifest.Files { + if e.Path != tilded { + filtered = append(filtered, e) + } + } + g.manifest.Files = filtered + } + + if err := g.manifest.Save(g.manifestPath); err != nil { + return fmt.Errorf("saving manifest: %w", err) + } + + return nil +} diff --git a/garden/remove_test.go b/garden/remove_test.go new file mode 100644 index 0000000..7963ecf --- /dev/null +++ b/garden/remove_test.go @@ -0,0 +1,65 @@ +package garden + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRemoveTrackedFile(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + // Create and add a file. + testFile := filepath.Join(root, "testfile") + if err := os.WriteFile(testFile, []byte("hello\n"), 0o644); err != nil { + t.Fatalf("writing test file: %v", err) + } + + if err := g.Add([]string{testFile}); err != nil { + t.Fatalf("Add: %v", err) + } + + if len(g.manifest.Files) != 1 { + t.Fatalf("expected 1 file after add, got %d", len(g.manifest.Files)) + } + + // Remove it. + if err := g.Remove([]string{testFile}); err != nil { + t.Fatalf("Remove: %v", err) + } + + if len(g.manifest.Files) != 0 { + t.Errorf("expected 0 files after remove, got %d", len(g.manifest.Files)) + } + + // Verify the manifest was persisted. + g2, err := Open(repoDir) + if err != nil { + t.Fatalf("re-Open: %v", err) + } + if len(g2.manifest.Files) != 0 { + t.Errorf("persisted manifest has %d files, want 0", len(g2.manifest.Files)) + } +} + +func TestRemoveUntrackedPathErrors(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + // Try removing a path that was never added. + bogus := filepath.Join(root, "nonexistent") + if err := g.Remove([]string{bogus}); err == nil { + t.Fatal("Remove of untracked path should return an error") + } +}