diff --git a/CONTEXT.md b/CONTEXT.md index dd6dfe5..8982316 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -5,9 +5,17 @@ A terminal UI for jumping to recently used projects. It remembers where you have ## Language **Project**: -A directory holding a git repository (a `.git` directory or file), identified by its absolute path. It may sit at any depth, and a directory that is not a repository is never a Project. +A directory holding a git repository (a `.git` directory or file), or a directory added with `cdd add`, identified by its absolute path. It may sit at any depth. A directory that is neither is not a Project, and neither is a Forgotten one. _Avoid_: Repo, workspace, folder, directory +**Added**: +Made a Project by hand with `cdd add`, whether or not it holds a `.git`. An Added directory is listed in the Picker even with no Visits, after the visited ones, and is Stale once the directory is gone. It is recorded in a cdd-owned file next to History; cdd never writes into the directory itself. +_Avoid_: Pinned, registered, tracked, bookmarked + +**Forgotten**: +Hidden for good with `cdd forget`, git repository or not. A Forgotten Project is never listed in the Picker and a Scan does not seed it. `cdd add` brings it back. +_Avoid_: Deleted, removed, ignored, excluded, hidden + **Jump**: Changing the shell's working directory to a chosen Project. _Avoid_: cd, navigate, switch, open, go to @@ -17,7 +25,7 @@ A single recorded Jump to a Project, or Action run on one, or an entry seeded fo _Avoid_: Access, hit, entry, usage **Stale Visit**: -A Visit whose Project no longer has a `.git` at its path, whatever the cause. It contributes nothing to the Picker and is never pruned; it ages out of History. +A Visit whose Project no longer qualifies at its path, whatever the cause: there is no `.git`, and it is not an Added directory that still exists. It contributes nothing to the Picker and is never pruned; it ages out of History. _Avoid_: Orphan, dead entry, dangling, missing project **History**: @@ -25,7 +33,7 @@ The ordered record of Visits from which recent Projects are derived. _Avoid_: Cache, log, database, store **Scan**: -A one-off sweep of the directories given to it (the home directory by default) that seeds History with a Visit per Project found at any depth below them, dated from evidence found inside the Project itself. Nothing about which directories were swept is remembered. +A one-off sweep of the directories given to it (the home directory by default) that seeds History with a Visit per Project found at any depth below them, dated from evidence found inside the Project itself. It skips Forgotten Projects. Nothing about which directories were swept is remembered. _Avoid_: Import, index, crawl, rebuild **Picker**: diff --git a/README.md b/README.md index ee2d63b..619fcf1 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,25 @@ cdd # open the Picker over History, ordered by recency `cdd` takes no query: it always opens the Picker, and you filter by typing into it. For a typed jump from the command line, use a tool like zoxide. The Picker lists only Projects in History, and drops any whose `.git` has -since gone. +since gone, along with any you have forgotten. + +### Adding and forgetting Projects + +A directory without a `.git` is not a Project until you add it: + +```sh +cdd add ~/notes # a Project from now on; it shows in the Picker at once +cdd add # the current directory +cdd forget ~/old # hide a Project for good, git repository or not +cdd forget # the current directory +``` + +An added directory is listed even with no Visits (after the visited ones) +and drops out once the directory is gone. A forgotten Project is never +listed, and `cdd scan` does not bring it back. `cdd add` on a forgotten +Project undoes the forget. cdd records both in its own `projects` file and +never writes into your directories. See +[ADR 0002](docs/adr/0002-projects-can-be-added-by-hand.md). ### Query syntax @@ -298,7 +316,9 @@ to rebuild History, since the old entries were stored relative to Root and are ignored. History is stored at `$XDG_DATA_HOME/cdd/history`, falling back to -`~/.local/share/cdd/history` when `XDG_DATA_HOME` is unset. +`~/.local/share/cdd/history` when `XDG_DATA_HOME` is unset. The `projects` +file written by `cdd add` and `cdd forget` sits beside it. Each line is +`add ` or `forget `, and the last line for a path wins. ## How it works @@ -308,7 +328,9 @@ Visit per git repository it finds, dated from the repository's last commit Projects drawn from History alone, most recently visited first, so opening it never walks the disk; choosing a Project records a new Visit and hands its path to the Wrapper, which turns it into a Jump in your shell. See -[ADR 0001](docs/adr/0001-projects-are-git-repos-found-by-scan.md) for why. +[ADR 0001](docs/adr/0001-projects-are-git-repos-found-by-scan.md) for why, +and [ADR 0002](docs/adr/0002-projects-can-be-added-by-hand.md) for the +directories you add by hand. ## Contributing diff --git a/docs/adr/0001-projects-are-git-repos-found-by-scan.md b/docs/adr/0001-projects-are-git-repos-found-by-scan.md index 5e42fc4..da6fb4c 100644 --- a/docs/adr/0001-projects-are-git-repos-found-by-scan.md +++ b/docs/adr/0001-projects-are-git-repos-found-by-scan.md @@ -1,5 +1,7 @@ # Projects are git repositories found by Scan +_Partly superseded by [0002](0002-projects-can-be-added-by-hand.md): a directory added with `cdd add` is a Project too._ + Until v0.3.0 cdd searched a configured Root with a fixed shape: every directory under Root was a Kind, every directory under a Kind was a Project, and any directory counted. That shape forced one layout onto the disk and listed folders that were never meant to be jumped to, while repositories nested deeper, or kept outside Root, could not be reached at all. A Project is now any directory holding a `.git` directory or file, at any depth, and there is no Root or Kind to configure. Only `cdd scan [dir...]` walks the disk (the home directory by default); it seeds History with absolute paths, and the Picker lists History alone, dropping any Visit whose `.git` has gone. The walk stops descending at the first repository it finds, except in a directory named to Scan, so a dotfiles repository at the home directory does not hide everything under it. diff --git a/docs/adr/0002-projects-can-be-added-by-hand.md b/docs/adr/0002-projects-can-be-added-by-hand.md new file mode 100644 index 0000000..4ddd9e0 --- /dev/null +++ b/docs/adr/0002-projects-can-be-added-by-hand.md @@ -0,0 +1,20 @@ +# Projects can be added by hand + +ADR 0001 made a Project a directory holding a `.git`, found by Scan, and said a directory that is not a repository is never a Project. That left no way to jump to the directories people work in that are not repositories (notes, a scratch folder, a monorepo's subdirectory), and no way to drop a repository Scan keeps finding. + +This supersedes 0001 in part. A Project is now a git repository **or** a directory the user added with `cdd add [dir]`, and `cdd forget [dir]` hides any Project, git or not, for good. The rest of 0001 stands: Scan is the only walk of the disk, it seeds History, and the Picker lists History without walking. + +Both are recorded in one file, `projects`, in the directory that holds History (`$XDG_DATA_HOME/cdd`). Each line is `add ` or `forget `, and the last line for a path wins, so `cdd add` brings back a Forgotten Project. Lines are appended and never rewritten, so concurrent writers lose nothing. The Picker lists an Added directory like any Project, ordered by its Visits, and after the visited ones when it has none. It is Stale once the directory is gone. A Forgotten Project is never listed and Scan does not seed it. + +## Considered options + +- **A marker file in the directory (a `.cdd`, say).** It travels with the directory, but cdd would write into the user's Projects, and `forget` could not hide a repository without touching it. cdd never writes into a Project. +- **Mark a Forgotten Project in History.** History is bounded and compacts its oldest lines away, so a Forgotten Project would come back when Scan next found it. The decision must be durable. +- **Store it in `config.toml`.** The file is the user's to edit by hand; a command that rewrites it would clobber their comments and formatting. +- **A directory is a Project if it has any of a list of marker files (`go.mod`, `package.json`).** A guess that is wrong both ways, and one more thing to configure. + +## Consequences + +- The `projects` file sits beside History, not in the state directory, so the two stay together when someone copies or deletes one data directory. It is not compacted; it grows by one short line per command. +- Forgetting is by exact path. A Forgotten repository that moves is a new Project. +- A Forgotten Project's History lines stay until they age out; the Picker simply does not list them. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 78db3b0..67eeaa8 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -1,5 +1,6 @@ // Package cli wires cdd's command surface with github.com/spf13/cobra: the -// root command (bare behaves as pick), pick, init, scan, version, plus +// root command (bare behaves as pick), pick, init, scan, add, forget, +// version, plus // cobra's own help and completion commands. package cli @@ -57,7 +58,8 @@ func Run(args []string, stdout, stderr io.Writer) int { } // newRootCmd builds the cdd command tree: root (bare behaves as pick), -// pick, init, scan, version, plus cobra's help and completion commands. +// pick, init, scan, add, forget, version, plus cobra's help and completion +// commands. func newRootCmd() *cobra.Command { root := &cobra.Command{ Use: "cdd", @@ -73,7 +75,7 @@ func newRootCmd() *cobra.Command { return pickRunE(cmd, nil) } - root.AddCommand(newPickCmd(), newInitCmd(), newScanCmd(), newVersionCmd()) + root.AddCommand(newPickCmd(), newInitCmd(), newScanCmd(), newAddCmd(), newForgetCmd(), newVersionCmd()) // Initialized here, not left to Execute, so the command tree is // complete (including "help" and "completion") whenever init's diff --git a/internal/cli/pick.go b/internal/cli/pick.go index be081cc..d3139f7 100644 --- a/internal/cli/pick.go +++ b/internal/cli/pick.go @@ -10,6 +10,7 @@ import ( "github.com/kryft-dev/cdd/internal/history" "github.com/kryft-dev/cdd/internal/jump" "github.com/kryft-dev/cdd/internal/picker" + "github.com/kryft-dev/cdd/internal/project" ) // initHint is printed to stderr, after the chosen path, when stdout is a @@ -28,7 +29,7 @@ func newPickCmd() *cobra.Command { } // pickRunE implements both "cdd pick" and the bare "cdd" root command: -// load config, open History at its default path, resolve a Project via +// load config, open History and the projects file at their default paths, resolve a Project via // jump.Resolve and picker.Run, and print the chosen absolute path plus a newline to stdout and nothing else. // An Action that does not Jump prints nothing, so the Wrapper has nothing // to cd to. @@ -53,7 +54,12 @@ func pickRunE(cmd *cobra.Command, _ []string) error { return err } - abs, err := jump.Resolve(cmd.Context(), cfg, hist, picker.Run, action.ExecRunner{}) + storePath, err := project.StorePath() + if err != nil { + return fmt.Errorf("cdd: locate the projects file: %w", err) + } + + abs, err := jump.Resolve(cmd.Context(), cfg, hist, project.OpenStore(storePath), picker.Run, action.ExecRunner{}) if err != nil { return err } diff --git a/internal/cli/projects.go b/internal/cli/projects.go new file mode 100644 index 0000000..843267e --- /dev/null +++ b/internal/cli/projects.go @@ -0,0 +1,110 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/kryft-dev/cdd/internal/config" + "github.com/kryft-dev/cdd/internal/history" + "github.com/kryft-dev/cdd/internal/project" +) + +// newAddCmd builds "cdd add [dir]": make dir (the current directory by +// default) a Project even without a .git, bring it back if it was Forgotten, +// and Record a Visit so it shows in the Picker at once. +func newAddCmd() *cobra.Command { + return &cobra.Command{ + Use: "add [dir]", + Short: "make a directory a Project, git repository or not (default: current directory)", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dir, err := absDir(args) + if err != nil { + return err + } + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + return fmt.Errorf("cdd add: %q is not a directory", dir) + } + + store, err := openStore() + if err != nil { + return err + } + if err := store.Add(dir); err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + histPath, err := history.DefaultPath() + if err != nil { + return fmt.Errorf("cdd: locate History: %w", err) + } + hist, err := history.Open(histPath, cfg.History.MaxVisits) + if err != nil { + return err + } + if err := hist.Record(dir); err != nil { + return err + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Added %s\n", dir) + return err + }, + } +} + +// newForgetCmd builds "cdd forget [dir]": hide a Project, git or not, from +// the Picker and from Scan until it is added again. The directory need not +// exist any more. +func newForgetCmd() *cobra.Command { + return &cobra.Command{ + Use: "forget [dir]", + Short: "hide a Project from the Picker and Scan (default: current directory)", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dir, err := absDir(args) + if err != nil { + return err + } + store, err := openStore() + if err != nil { + return err + } + if err := store.Forget(dir); err != nil { + return err + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Forgot %s\n", dir) + return err + }, + } +} + +// absDir returns the cleaned absolute path of the one optional argument, or +// of the current directory when there is none. +func absDir(args []string) (string, error) { + dir := "." + if len(args) == 1 { + dir = args[0] + } + abs, err := filepath.Abs(dir) + if err != nil { + return "", fmt.Errorf("cdd: resolve %q: %w", dir, err) + } + return abs, nil +} + +// openStore opens the projects file at its default path. +func openStore() (*project.Store, error) { + path, err := project.StorePath() + if err != nil { + return nil, fmt.Errorf("cdd: locate the projects file: %w", err) + } + return project.OpenStore(path), nil +} diff --git a/internal/cli/projects_test.go b/internal/cli/projects_test.go new file mode 100644 index 0000000..d6f4e76 --- /dev/null +++ b/internal/cli/projects_test.go @@ -0,0 +1,210 @@ +package cli_test + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/kryft-dev/cdd/internal/history" + "github.com/kryft-dev/cdd/internal/project" +) + +// isolate points cdd's data and config at fresh temp directories and +// returns a directory to use as a Project. +func isolate(t *testing.T) string { + t.Helper() + + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + dir := filepath.Join(t.TempDir(), "notes") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + return dir +} + +// marks reads the projects file under the isolated XDG_DATA_HOME. +func marks(t *testing.T) project.Marks { + t.Helper() + + path, err := project.StorePath() + if err != nil { + t.Fatalf("StorePath: %v", err) + } + m, err := project.OpenStore(path).Marks() + if err != nil { + t.Fatalf("Marks: %v", err) + } + return m +} + +// visited returns the Projects in the isolated History. +func visited(t *testing.T) []string { + t.Helper() + + path, err := history.DefaultPath() + if err != nil { + t.Fatalf("DefaultPath: %v", err) + } + h, err := history.Open(path, 1000) + if err != nil { + t.Fatalf("Open: %v", err) + } + latest, err := h.Latest() + if err != nil { + t.Fatalf("Latest: %v", err) + } + var out []string + for _, v := range latest { + out = append(out, v.Project) + } + return out +} + +func TestAdd_RecordsProjectAndVisit(t *testing.T) { + dir := isolate(t) + + code, stdout, stderr := runCLI(t, "add", dir) + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %q)", code, stderr) + } + if want := "Added " + dir + "\n"; stdout != want { + t.Errorf("stdout = %q, want %q", stdout, want) + } + if !marks(t).IsAdded(dir) { + t.Error("the directory should be Added") + } + if got := visited(t); !slices.Equal(got, []string{dir}) { + t.Errorf("History = %v, want [%s]", got, dir) + } +} + +func TestAdd_DefaultsToCurrentDirectory(t *testing.T) { + dir := isolate(t) + t.Chdir(dir) + want, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + + if code, _, stderr := runCLI(t, "add"); code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %q)", code, stderr) + } + if !marks(t).IsAdded(want) { + t.Errorf("the current directory %s should be Added", want) + } +} + +func TestAdd_ResolvesRelativeAndCleansPath(t *testing.T) { + dir := isolate(t) + t.Chdir(filepath.Dir(dir)) + want, err := filepath.Abs("notes") + if err != nil { + t.Fatalf("Abs: %v", err) + } + + if code, _, stderr := runCLI(t, "add", "./notes/../notes/"); code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %q)", code, stderr) + } + if !marks(t).IsAdded(want) { + t.Errorf("%s should be Added", want) + } +} + +func TestAdd_MissingDirectoryFails(t *testing.T) { + dir := isolate(t) + missing := filepath.Join(dir, "nope") + + code, _, stderr := runCLI(t, "add", missing) + if code != 1 { + t.Errorf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr, "not a directory") { + t.Errorf("stderr = %q, want it to say %q", stderr, "not a directory") + } + if marks(t).IsAdded(missing) { + t.Error("a missing directory must not be Added") + } +} + +func TestAdd_FileFails(t *testing.T) { + dir := isolate(t) + file := filepath.Join(dir, "f") + if err := os.WriteFile(file, nil, 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if code, _, _ := runCLI(t, "add", file); code != 1 { + t.Errorf("exit code = %d, want 1", code) + } +} + +func TestAdd_TooManyArgsIsUsageError(t *testing.T) { + isolate(t) + + if code, _, _ := runCLI(t, "add", "a", "b"); code != 2 { + t.Errorf("exit code = %d, want 2", code) + } +} + +func TestForget_ThenAddBringsItBack(t *testing.T) { + dir := isolate(t) + + code, stdout, stderr := runCLI(t, "forget", dir) + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %q)", code, stderr) + } + if want := "Forgot " + dir + "\n"; stdout != want { + t.Errorf("stdout = %q, want %q", stdout, want) + } + if !marks(t).IsForgotten(dir) { + t.Error("the directory should be Forgotten") + } + + if code, _, stderr := runCLI(t, "add", dir); code != 0 { + t.Fatalf("add: exit code = %d, want 0 (stderr: %q)", code, stderr) + } + if m := marks(t); m.IsForgotten(dir) || !m.IsAdded(dir) { + t.Error("add should bring a Forgotten Project back") + } +} + +func TestForget_AcceptsAMissingDirectory(t *testing.T) { + dir := isolate(t) + missing := filepath.Join(dir, "gone") + + if code, _, stderr := runCLI(t, "forget", missing); code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %q)", code, stderr) + } + if !marks(t).IsForgotten(missing) { + t.Error("a Project whose directory is gone can still be Forgotten") + } +} + +func TestForget_DefaultsToCurrentDirectory(t *testing.T) { + dir := isolate(t) + t.Chdir(dir) + want, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + + if code, _, stderr := runCLI(t, "forget"); code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %q)", code, stderr) + } + if !marks(t).IsForgotten(want) { + t.Errorf("the current directory %s should be Forgotten", want) + } +} + +func TestInitPassthrough_IncludesAddAndForget(t *testing.T) { + _, stdout, _ := runCLI(t, "init", "bash") + + for _, name := range []string{"add|", "forget|"} { + if !strings.Contains(stdout, name) { + t.Errorf("bash Wrapper does not pass %q through:\n%s", name, stdout) + } + } +} diff --git a/internal/cli/scan.go b/internal/cli/scan.go index 36a5237..68048fe 100644 --- a/internal/cli/scan.go +++ b/internal/cli/scan.go @@ -8,6 +8,7 @@ import ( "github.com/kryft-dev/cdd/internal/config" "github.com/kryft-dev/cdd/internal/history" + "github.com/kryft-dev/cdd/internal/project" "github.com/kryft-dev/cdd/internal/scan" ) @@ -42,7 +43,12 @@ func newScanCmd() *cobra.Command { return err } - summary, err := scan.Run(cmd.Context(), dirs, cfg, hist) + storePath, err := project.StorePath() + if err != nil { + return fmt.Errorf("cdd: locate the projects file: %w", err) + } + + summary, err := scan.Run(cmd.Context(), dirs, cfg, hist, project.OpenStore(storePath)) if err != nil { return err } diff --git a/internal/jump/action_test.go b/internal/jump/action_test.go index e8ec371..79870f6 100644 --- a/internal/jump/action_test.go +++ b/internal/jump/action_test.go @@ -58,7 +58,7 @@ func TestResolve_AttachedActionRunsOnTheTerminalAndRecordsAVisit(t *testing.T) { run := &fakeRunner{} var opts picker.Options - got, err := jump.Resolve(context.Background(), cfg, hist, pickWith(action.Action{Name: "lazygit", Run: "lazygit"}, path, &opts), run) + got, err := jump.Resolve(context.Background(), cfg, hist, newStore(t), pickWith(action.Action{Name: "lazygit", Run: "lazygit"}, path, &opts), run) if err != nil || got != "" { t.Errorf("Resolve = %q, %v, want no path and no error", got, err) @@ -78,7 +78,7 @@ func TestResolve_JumpActionReturnsThePathAfterTheCommand(t *testing.T) { run := &fakeRunner{} var opts picker.Options - got, err := jump.Resolve(context.Background(), cfg, hist, pickWith(action.Action{Name: "fmt", Run: "gofmt", Jump: true}, path, &opts), run) + got, err := jump.Resolve(context.Background(), cfg, hist, newStore(t), pickWith(action.Action{Name: "fmt", Run: "gofmt", Jump: true}, path, &opts), run) if err != nil || got != path { t.Errorf("Resolve = %q, %v, want %q", got, err, path) @@ -95,7 +95,7 @@ func TestResolve_ActionWithoutACommandOnlyJumps(t *testing.T) { run := &fakeRunner{} var opts picker.Options - got, err := jump.Resolve(context.Background(), cfg, hist, pickWith(action.Action{Name: "jump", Jump: true}, path, &opts), run) + got, err := jump.Resolve(context.Background(), cfg, hist, newStore(t), pickWith(action.Action{Name: "jump", Jump: true}, path, &opts), run) if err != nil || got != path || len(run.ran) != 0 { t.Errorf("Resolve = %q, %v, ran %v, want the path and no command", got, err, run.ran) @@ -108,7 +108,7 @@ func TestResolve_FailingCommandReturnsItsExitStatusAndNoPath(t *testing.T) { path := filepath.Join(root, "tools", "cdd") var opts picker.Options - got, err := jump.Resolve(context.Background(), cfg, hist, pickWith(action.Action{Name: "x", Run: "x", Jump: true}, path, &opts), &fakeRunner{code: 3}) + got, err := jump.Resolve(context.Background(), cfg, hist, newStore(t), pickWith(action.Action{Name: "x", Run: "x", Jump: true}, path, &opts), &fakeRunner{code: 3}) var exit *jump.ExitError if !errors.As(err, &exit) || exit.Code != 3 || got != "" { @@ -122,7 +122,7 @@ func TestResolve_CommandThatCannotRunIsAnError(t *testing.T) { path := filepath.Join(root, "tools", "cdd") var opts picker.Options - _, err := jump.Resolve(context.Background(), cfg, hist, pickWith(action.Action{Name: "x", Run: "x"}, path, &opts), &fakeRunner{runErr: errors.New("no tty")}) + _, err := jump.Resolve(context.Background(), cfg, hist, newStore(t), pickWith(action.Action{Name: "x", Run: "x"}, path, &opts), &fakeRunner{runErr: errors.New("no tty")}) var exit *jump.ExitError if err == nil || errors.As(err, &exit) { @@ -138,7 +138,7 @@ func TestResolve_PickerGetsTheResolvedActionsAndARecordingRunner(t *testing.T) { run := &fakeRunner{} var opts picker.Options - if _, err := jump.Resolve(context.Background(), cfg, hist, pickWith(action.Action{Name: "jump", Jump: true}, path, &opts), run); err != nil { + if _, err := jump.Resolve(context.Background(), cfg, hist, newStore(t), pickWith(action.Action{Name: "jump", Jump: true}, path, &opts), run); err != nil { t.Fatalf("Resolve: %v", err) } if len(opts.Actions) != 1 || opts.Actions[0].Name != "code" { @@ -173,7 +173,7 @@ func TestResolve_PickerHidesHintsWhenConfigSaysSo(t *testing.T) { cfg.Picker.Hints = hints var opts picker.Options - if _, err := jump.Resolve(context.Background(), cfg, hist, pickWith(action.Action{Name: "jump", Jump: true}, path, &opts), &fakeRunner{}); err != nil { + if _, err := jump.Resolve(context.Background(), cfg, hist, newStore(t), pickWith(action.Action{Name: "jump", Jump: true}, path, &opts), &fakeRunner{}); err != nil { t.Fatalf("Resolve: %v", err) } if opts.HideHints == hints { diff --git a/internal/jump/jump.go b/internal/jump/jump.go index 587a068..a233a1b 100644 --- a/internal/jump/jump.go +++ b/internal/jump/jump.go @@ -31,8 +31,9 @@ func (e *ExitError) Error() string { return fmt.Sprintf("jump: action exited wit type PickFunc func(rows []picker.Row, status picker.StatusFunc, opts picker.Options) (picker.Choice, bool, error) // Resolve runs the pick flow that turns History into the absolute path of -// the Project to Jump to: read its latest Visits, drop the Stale ones, run -// the Picker (via pick), confirm the chosen directory still exists, Record +// the Project to Jump to: read its latest Visits, drop the Stale and +// Forgotten ones, list the Added Projects that have no Visit last, run the +// Picker (via pick), confirm the chosen directory still exists, Record // the Visit, and return the path. // // When the user ran an Action that does not detach, Resolve also runs its @@ -43,18 +44,23 @@ type PickFunc func(rows []picker.Row, status picker.StatusFunc, opts picker.Opti // // A cancelled Picker yields ErrCancelled. A Visit that fails to Record // only prints a warning to stderr; Resolve still returns the path. -func Resolve(ctx context.Context, cfg config.Config, hist *history.History, pick PickFunc, run action.Runner) (string, error) { +func Resolve(ctx context.Context, cfg config.Config, hist *history.History, store *project.Store, pick PickFunc, run action.Runner) (string, error) { latest, err := hist.Latest() if err != nil { return "", fmt.Errorf("jump: %w", err) } + marks, err := store.Marks() + if err != nil { + return "", fmt.Errorf("jump: %w", err) + } + counts, err := hist.Counts() if err != nil { return "", fmt.Errorf("jump: %w", err) } - choice, err := choose(cfg, live(latest), counts, pick, recorder{run, hist}) + choice, err := choose(cfg, listed(latest, marks), counts, pick, recorder{run, hist}) if err != nil { return "", err } @@ -107,18 +113,6 @@ func (r recorder) Start(a action.Action, path string) error { return nil } -// live drops each Stale Visit from latest: one whose Project no longer -// holds a git repository. -func live(latest []history.Visit) []history.Visit { - out := make([]history.Visit, 0, len(latest)) - for _, v := range latest { - if project.IsRepo(v.Project) { - out = append(out, v) - } - } - return out -} - // choose runs the Picker over latest, starting its detached Actions with // run, and returns what it chose. func choose(cfg config.Config, latest []history.Visit, counts map[string]int, pick PickFunc, run action.Runner) (picker.Choice, error) { diff --git a/internal/jump/listed.go b/internal/jump/listed.go new file mode 100644 index 0000000..3d9d199 --- /dev/null +++ b/internal/jump/listed.go @@ -0,0 +1,44 @@ +package jump + +import ( + "os" + + "github.com/kryft-dev/cdd/internal/history" + "github.com/kryft-dev/cdd/internal/project" +) + +// listed returns the Visits the Picker shows: latest without its Forgotten +// and Stale Visits, then one Visit-less entry for each Added Project that +// has no Visit, in path order. A Visit is Stale when its Project holds no +// git repository and is not an Added directory that still exists. +func listed(latest []history.Visit, marks project.Marks) []history.Visit { + out := make([]history.Visit, 0, len(latest)) + seen := make(map[string]bool, len(latest)) + for _, v := range latest { + seen[v.Project] = true + if live(v.Project, marks) { + out = append(out, v) + } + } + for _, p := range marks.Added() { + if !seen[p] && isDir(p) { + out = append(out, history.Visit{Project: p}) + } + } + return out +} + +// live reports whether path is still a Project: not Forgotten, and either a +// git repository or an Added directory that exists. +func live(path string, marks project.Marks) bool { + if marks.IsForgotten(path) { + return false + } + return project.IsRepo(path) || (marks.IsAdded(path) && isDir(path)) +} + +// isDir reports whether path is an existing directory. +func isDir(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} diff --git a/internal/jump/listed_test.go b/internal/jump/listed_test.go new file mode 100644 index 0000000..526b4c8 --- /dev/null +++ b/internal/jump/listed_test.go @@ -0,0 +1,163 @@ +package jump_test + +import ( + "context" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/kryft-dev/cdd/internal/history" + "github.com/kryft-dev/cdd/internal/jump" + "github.com/kryft-dev/cdd/internal/picker" + "github.com/kryft-dev/cdd/internal/project" +) + +// mkDir creates a plain directory, no .git, at rel under root. +func mkDir(t *testing.T, root, rel string) string { + t.Helper() + + dir := filepath.Join(root, rel) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll(%q): %v", rel, err) + } + return dir +} + +// shown runs Resolve with a Picker that cancels and returns the paths of +// the rows it was given. +func shown(t *testing.T, hist *history.History, store *project.Store) []string { + t.Helper() + + cfg, _ := mkProjects(t, hist) + var rows []picker.Row + var opts picker.Options + _, _ = jump.Resolve(context.Background(), cfg, hist, store, capturePick(&rows, &opts, ""), &fakeRunner{}) + + var paths []string + for _, r := range rows { + paths = append(paths, r.Project.Path) + } + return paths +} + +func TestResolve_AddedDirectoryWithVisitIsListed(t *testing.T) { + hist := newHistory(t) + store := newStore(t) + dir := mkDir(t, t.TempDir(), "notes") + if err := store.Add(dir); err != nil { + t.Fatalf("Add: %v", err) + } + if err := hist.Record(dir); err != nil { + t.Fatalf("Record: %v", err) + } + + if got := shown(t, hist, store); !slices.Equal(got, []string{dir}) { + t.Errorf("rows = %v, want [%s]", got, dir) + } +} + +func TestResolve_VisitedPlainDirectoryNotAddedIsStale(t *testing.T) { + hist := newHistory(t) + dir := mkDir(t, t.TempDir(), "notes") + if err := hist.Record(dir); err != nil { + t.Fatalf("Record: %v", err) + } + + if got := shown(t, hist, newStore(t)); len(got) != 0 { + t.Errorf("rows = %v, want none", got) + } +} + +func TestResolve_AddedDirectoryWithoutVisitsComesAfterVisited(t *testing.T) { + hist := newHistory(t) + store := newStore(t) + _, root := mkProjects(t, hist, "repo") + b, a := mkDir(t, root, "b"), mkDir(t, root, "a") + for _, d := range []string{b, a} { + if err := store.Add(d); err != nil { + t.Fatalf("Add: %v", err) + } + } + + want := []string{filepath.Join(root, "repo"), a, b} + if got := shown(t, hist, store); !slices.Equal(got, want) { + t.Errorf("rows = %v, want %v", got, want) + } +} + +func TestResolve_AddedDirectoryIsStaleOnceGone(t *testing.T) { + hist := newHistory(t) + store := newStore(t) + dir := mkDir(t, t.TempDir(), "notes") + if err := store.Add(dir); err != nil { + t.Fatalf("Add: %v", err) + } + if err := hist.Record(dir); err != nil { + t.Fatalf("Record: %v", err) + } + other := mkDir(t, t.TempDir(), "never-visited") + if err := store.Add(other); err != nil { + t.Fatalf("Add: %v", err) + } + for _, d := range []string{dir, other} { + if err := os.Remove(d); err != nil { + t.Fatalf("Remove: %v", err) + } + } + + if got := shown(t, hist, store); len(got) != 0 { + t.Errorf("rows = %v, want none", got) + } +} + +func TestResolve_ForgottenRepositoryIsHidden(t *testing.T) { + hist := newHistory(t) + store := newStore(t) + _, root := mkProjects(t, hist, "gone", "kept") + if err := store.Forget(filepath.Join(root, "gone")); err != nil { + t.Fatalf("Forget: %v", err) + } + + if got, want := shown(t, hist, store), []string{filepath.Join(root, "kept")}; !slices.Equal(got, want) { + t.Errorf("rows = %v, want %v", got, want) + } +} + +func TestResolve_ReAddedRepositoryIsListedAgain(t *testing.T) { + hist := newHistory(t) + store := newStore(t) + _, root := mkProjects(t, hist, "back") + dir := filepath.Join(root, "back") + if err := store.Forget(dir); err != nil { + t.Fatalf("Forget: %v", err) + } + if err := store.Add(dir); err != nil { + t.Fatalf("Add: %v", err) + } + + if got := shown(t, hist, store); !slices.Equal(got, []string{dir}) { + t.Errorf("rows = %v, want [%s]", got, dir) + } +} + +func TestResolve_AddedDirectoryJumps(t *testing.T) { + hist := newHistory(t) + store := newStore(t) + cfg, root := mkProjects(t, hist) + dir := mkDir(t, root, "notes") + if err := store.Add(dir); err != nil { + t.Fatalf("Add: %v", err) + } + + var rows []picker.Row + var opts picker.Options + got, err := jump.Resolve(context.Background(), cfg, hist, store, capturePick(&rows, &opts, dir), &fakeRunner{}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != dir { + t.Errorf("Resolve = %q, want %q", got, dir) + } + assertRecorded(t, hist, dir) +} diff --git a/internal/jump/resolve_test.go b/internal/jump/resolve_test.go index c93d7ac..15b4e61 100644 --- a/internal/jump/resolve_test.go +++ b/internal/jump/resolve_test.go @@ -13,6 +13,7 @@ import ( "github.com/kryft-dev/cdd/internal/history" "github.com/kryft-dev/cdd/internal/jump" "github.com/kryft-dev/cdd/internal/picker" + "github.com/kryft-dev/cdd/internal/project" ) // mkProjects creates each rel path under a fresh root as a Project (a @@ -58,6 +59,13 @@ func newHistory(t *testing.T) *history.History { return h } +// newStore opens an empty project Store in a fresh temp directory. +func newStore(t *testing.T) *project.Store { + t.Helper() + + return project.OpenStore(filepath.Join(t.TempDir(), "projects")) +} + // TestResolve_AlwaysOpensPickerWithEmptyQuery checks that the Picker opens // with nothing typed, even when History holds a single Project, and that // the chosen Project is returned and Recorded. @@ -68,7 +76,7 @@ func TestResolve_AlwaysOpensPickerWithEmptyQuery(t *testing.T) { var rows []picker.Row var opts picker.Options - got, err := jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &opts, want), &fakeRunner{}) + got, err := jump.Resolve(context.Background(), cfg, hist, newStore(t), capturePick(&rows, &opts, want), &fakeRunner{}) if err != nil { t.Fatalf("Resolve: unexpected error: %v", err) } @@ -100,7 +108,7 @@ func TestResolve_RowsComeFromHistoryNewestFirst(t *testing.T) { var rows []picker.Row var opts picker.Options - _, _ = jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &opts, ""), &fakeRunner{}) + _, _ = jump.Resolve(context.Background(), cfg, hist, newStore(t), capturePick(&rows, &opts, ""), &fakeRunner{}) var got []string for _, r := range rows { @@ -121,7 +129,7 @@ func TestResolve_StaleVisitGivesNoRow(t *testing.T) { var rows []picker.Row var opts picker.Options - _, _ = jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &opts, ""), &fakeRunner{}) + _, _ = jump.Resolve(context.Background(), cfg, hist, newStore(t), capturePick(&rows, &opts, ""), &fakeRunner{}) if len(rows) != 1 { t.Errorf("Picker got %d rows, want 1 (Stale Visit dropped)", len(rows)) } @@ -137,7 +145,7 @@ func TestResolve_ForwardsPickerOptions(t *testing.T) { var rows []picker.Row var got picker.Options - if _, err := jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &got, filepath.Join(root, "tools", "cdd")), &fakeRunner{}); err != nil { + if _, err := jump.Resolve(context.Background(), cfg, hist, newStore(t), capturePick(&rows, &got, filepath.Join(root, "tools", "cdd")), &fakeRunner{}); err != nil { t.Fatalf("Resolve: unexpected error: %v", err) } @@ -155,7 +163,7 @@ func TestResolve_CancelReturnsErrCancelled(t *testing.T) { var rows []picker.Row var opts picker.Options - _, err := jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &opts, ""), &fakeRunner{}) + _, err := jump.Resolve(context.Background(), cfg, hist, newStore(t), capturePick(&rows, &opts, ""), &fakeRunner{}) if !errors.Is(err, jump.ErrCancelled) { t.Fatalf("Resolve error = %v, want ErrCancelled", err) } @@ -168,7 +176,7 @@ func TestResolve_VanishedDirectoryErrors(t *testing.T) { var rows []picker.Row var opts picker.Options gone := filepath.Join(root, "tools", "vanished") - if _, err := jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &opts, gone), &fakeRunner{}); err == nil { + if _, err := jump.Resolve(context.Background(), cfg, hist, newStore(t), capturePick(&rows, &opts, gone), &fakeRunner{}); err == nil { t.Fatal("Resolve: want error for a chosen directory that no longer exists, got nil") } } @@ -192,7 +200,7 @@ func TestResolve_FailingHistoryWriteStillReturnsPath(t *testing.T) { want := filepath.Join(root, "tools", "cdd") var rows []picker.Row var opts picker.Options - got, err := jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &opts, want), &fakeRunner{}) + got, err := jump.Resolve(context.Background(), cfg, hist, newStore(t), capturePick(&rows, &opts, want), &fakeRunner{}) if err != nil { t.Fatalf("Resolve: unexpected error despite a failing History write: %v", err) } diff --git a/internal/project/store.go b/internal/project/store.go new file mode 100644 index 0000000..3671318 --- /dev/null +++ b/internal/project/store.go @@ -0,0 +1,153 @@ +package project + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/kryft-dev/cdd/internal/history" +) + +// maxLineBytes bounds one appended line, comfortably under the 4 KiB +// advisory limit below which a single O_APPEND write is not interleaved +// with another process's. +const maxLineBytes = 4096 + +const ( + verbAdd = "add" + verbForget = "forget" +) + +// Store is a handle to the on-disk file recording the Projects the user has +// Added or Forgotten by hand, one "add " or "forget " +// line each. The last line for a path wins. It holds no cached state; every +// call reads or writes the file named by path. cdd owns this file; it never +// writes into a Project. +type Store struct { + path string +} + +// StorePath returns the default Store file path, "projects" in the +// directory holding History. +func StorePath() (string, error) { + hist, err := history.DefaultPath() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(hist), "projects"), nil +} + +// OpenStore returns a Store over the file at path. A missing file is an +// empty Store, not an error; it is created by the first Add or Forget. +func OpenStore(path string) *Store { + return &Store{path: path} +} + +// Add marks project, an absolute path, as Added, bringing back a Forgotten +// one. It does not check that the directory exists; the cli does. +func (s *Store) Add(project string) error { + return s.append(verbAdd, project) +} + +// Forget marks project, an absolute path, as Forgotten: hidden from the +// Picker and skipped by a Scan until it is Added again. +func (s *Store) Forget(project string) error { + return s.append(verbForget, project) +} + +// append writes one line with a single O_APPEND write, which the kernel +// does not interleave with another process's, so concurrent writers lose +// nothing and need no lock: the file is never rewritten. +func (s *Store) append(verb, project string) error { + if !filepath.IsAbs(project) { + return fmt.Errorf("project: %q is not an absolute path", project) + } + if strings.Contains(project, "\n") { + return fmt.Errorf("project: %q contains a newline", project) + } + line := verb + " " + project + "\n" + if len(line) > maxLineBytes { + return fmt.Errorf("project: line for %q exceeds %d bytes", project, maxLineBytes) + } + + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return fmt.Errorf("project: %w", err) + } + f, err := os.OpenFile(s.path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644) + if err != nil { + return fmt.Errorf("project: %w", err) + } + if _, err := f.WriteString(line); err != nil { + _ = f.Close() + return fmt.Errorf("project: %w", err) + } + return f.Close() +} + +// Marks is what the Store holds once the last line for each path has won. +type Marks struct { + added map[string]bool + forgotten map[string]bool +} + +// Marks reads the Store. Malformed lines are skipped silently. +func (s *Store) Marks() (Marks, error) { + m := Marks{added: map[string]bool{}, forgotten: map[string]bool{}} + f, err := os.Open(s.path) + if err != nil { + if os.IsNotExist(err) { + return m, nil + } + return Marks{}, fmt.Errorf("project: %w", err) + } + defer func() { _ = f.Close() }() + + // bufio.Reader, not Scanner, so one over-long garbage line is skipped + // like any malformed line instead of aborting the read. + br := bufio.NewReader(f) + for { + line, err := br.ReadString('\n') + if verb, path, ok := parseLine(strings.TrimSuffix(line, "\n")); ok { + m.added[path] = verb == verbAdd + m.forgotten[path] = verb == verbForget + } + if err == io.EOF { + return m, nil + } + if err != nil { + return Marks{}, fmt.Errorf("project: %w", err) + } + } +} + +// parseLine splits a line into its verb and absolute path, reporting +// ok=false for anything else. +func parseLine(line string) (verb, path string, ok bool) { + verb, path, found := strings.Cut(line, " ") + if !found || (verb != verbAdd && verb != verbForget) || !filepath.IsAbs(path) { + return "", "", false + } + return verb, path, true +} + +// IsAdded reports whether project's last line is an add. +func (m Marks) IsAdded(project string) bool { return m.added[project] } + +// IsForgotten reports whether project's last line is a forget. +func (m Marks) IsForgotten(project string) bool { return m.forgotten[project] } + +// Added returns every Added path, sorted. +func (m Marks) Added() []string { + out := make([]string, 0, len(m.added)) + for p, ok := range m.added { + if ok { + out = append(out, p) + } + } + sort.Strings(out) + return out +} diff --git a/internal/project/store_test.go b/internal/project/store_test.go new file mode 100644 index 0000000..5b394fe --- /dev/null +++ b/internal/project/store_test.go @@ -0,0 +1,179 @@ +package project_test + +import ( + "os" + "path/filepath" + "slices" + "strconv" + "sync" + "testing" + + "github.com/kryft-dev/cdd/internal/project" +) + +func newStore(t *testing.T) (*project.Store, string) { + t.Helper() + + path := filepath.Join(t.TempDir(), "state", "projects") + return project.OpenStore(path), path +} + +func marks(t *testing.T, s *project.Store) project.Marks { + t.Helper() + + m, err := s.Marks() + if err != nil { + t.Fatalf("Marks: %v", err) + } + return m +} + +func TestStore_MissingFileIsEmpty(t *testing.T) { + s, _ := newStore(t) + + m := marks(t, s) + if len(m.Added()) != 0 || m.IsAdded("/p/a") || m.IsForgotten("/p/a") { + t.Errorf("a missing file should hold nothing, got added %v", m.Added()) + } +} + +func TestStore_AddThenForgetThenReAdd(t *testing.T) { + s, _ := newStore(t) + + if err := s.Add("/p/a"); err != nil { + t.Fatalf("Add: %v", err) + } + if m := marks(t, s); !m.IsAdded("/p/a") || m.IsForgotten("/p/a") { + t.Errorf("after Add: added=%v forgotten=%v", m.IsAdded("/p/a"), m.IsForgotten("/p/a")) + } + + if err := s.Forget("/p/a"); err != nil { + t.Fatalf("Forget: %v", err) + } + if m := marks(t, s); m.IsAdded("/p/a") || !m.IsForgotten("/p/a") { + t.Errorf("after Forget: added=%v forgotten=%v", m.IsAdded("/p/a"), m.IsForgotten("/p/a")) + } + + if err := s.Add("/p/a"); err != nil { + t.Fatalf("re-Add: %v", err) + } + if m := marks(t, s); !m.IsAdded("/p/a") || m.IsForgotten("/p/a") { + t.Errorf("after re-Add: added=%v forgotten=%v", m.IsAdded("/p/a"), m.IsForgotten("/p/a")) + } +} + +func TestStore_ForgetNeedsNoPriorAdd(t *testing.T) { + s, _ := newStore(t) + + if err := s.Forget("/p/repo"); err != nil { + t.Fatalf("Forget: %v", err) + } + if m := marks(t, s); !m.IsForgotten("/p/repo") || m.IsAdded("/p/repo") { + t.Errorf("a git Project can be Forgotten without being Added") + } +} + +func TestStore_AddedIsSortedAndSkipsForgotten(t *testing.T) { + s, _ := newStore(t) + for _, p := range []string{"/p/b", "/p/c", "/p/a"} { + if err := s.Add(p); err != nil { + t.Fatalf("Add: %v", err) + } + } + if err := s.Forget("/p/c"); err != nil { + t.Fatalf("Forget: %v", err) + } + + got := marks(t, s).Added() + if want := []string{"/p/a", "/p/b"}; !slices.Equal(got, want) { + t.Errorf("Added() = %v, want %v", got, want) + } +} + +func TestStore_WritesTheDocumentedLines(t *testing.T) { + s, path := newStore(t) + _ = s.Add("/p/a b") + _ = s.Forget("/p/c") + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if want := "add /p/a b\nforget /p/c\n"; string(got) != want { + t.Errorf("file = %q, want %q", got, want) + } + if !marks(t, s).IsAdded("/p/a b") { + t.Error("a path holding a space should round-trip") + } +} + +func TestStore_SkipsMalformedLines(t *testing.T) { + s, path := newStore(t) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + junk := "bogus /p/x\nadd relative/path\nadd\n\nforget /p/y\n" + if err := os.WriteFile(path, []byte(junk), 0o644); err != nil { + t.Fatal(err) + } + + m := marks(t, s) + if len(m.Added()) != 0 || !m.IsForgotten("/p/y") { + t.Errorf("only the well-formed line should count, got added %v", m.Added()) + } +} + +func TestStore_RejectsBadPaths(t *testing.T) { + s, _ := newStore(t) + + for _, p := range []string{"relative/dir", "/p/new\nline"} { + if err := s.Add(p); err == nil { + t.Errorf("Add(%q): want error, got nil", p) + } + if err := s.Forget(p); err == nil { + t.Errorf("Forget(%q): want error, got nil", p) + } + } +} + +func TestStore_ConcurrentWritesLoseNothing(t *testing.T) { + s, _ := newStore(t) + + const goroutines = 20 + const perGoroutine = 10 + var wg sync.WaitGroup + errs := make(chan error, goroutines) + for g := range goroutines { + wg.Add(1) + go func() { + defer wg.Done() + for i := range perGoroutine { + if err := s.Add("/p/" + strconv.Itoa(g) + "/" + strconv.Itoa(i)); err != nil { + errs <- err + return + } + } + }() + } + wg.Wait() + close(errs) + for err := range errs { + t.Fatalf("Add: %v", err) + } + + if got, want := len(marks(t, s).Added()), goroutines*perGoroutine; got != want { + t.Errorf("%d Projects survived, want %d", got, want) + } +} + +func TestStorePath_SitsBesideHistory(t *testing.T) { + t.Setenv("XDG_DATA_HOME", "/xdg/data") + + got, err := project.StorePath() + if err != nil { + t.Fatalf("StorePath: %v", err) + } + if want := filepath.Join("/xdg/data", "cdd", "projects"); got != want { + t.Errorf("StorePath() = %q, want %q", got, want) + } +} diff --git a/internal/scan/scan.go b/internal/scan/scan.go index e400408..3110ccd 100644 --- a/internal/scan/scan.go +++ b/internal/scan/scan.go @@ -6,6 +6,7 @@ package scan import ( "context" "fmt" + "slices" "time" "github.com/kryft-dev/cdd/internal/config" @@ -18,25 +19,32 @@ import ( type Summary struct { // Seeded is the number of Visits actually appended to History. Seeded int - // Projects is the number of Projects discovered. + // Projects is the number of Projects discovered, Forgotten ones left out. Projects int } // Run discovers Projects at any depth below dirs, honoring cfg.Exclude and -// cfg.IncludeHidden, and seeds hist with one Visit per Project. Each -// Project's evidence time is git.LastCommit when the repository has a -// commit, else the Project directory's mtime. +// cfg.IncludeHidden, and seeds hist with one Visit per Project, except the +// ones store holds as Forgotten. Each Project's evidence time is +// git.LastCommit when the repository has a commit, else the Project +// directory's mtime. // // Whether a given Project actually gains a new Visit is entirely up to // Seed's own idempotency rule (a Project with a newer real Visit is left // alone); Run does not re-implement that rule, only reports how many // Visits it produced. -func Run(ctx context.Context, dirs []string, cfg config.Config, hist *history.History) (Summary, error) { +func Run(ctx context.Context, dirs []string, cfg config.Config, hist *history.History, store *project.Store) (Summary, error) { projects, err := project.Discover(dirs, cfg.Exclude, cfg.IncludeHidden) if err != nil { return Summary{}, fmt.Errorf("scan: discover projects: %w", err) } + marks, err := store.Marks() + if err != nil { + return Summary{}, fmt.Errorf("scan: %w", err) + } + projects = slices.DeleteFunc(projects, marks.IsForgotten) + evidence, err := gatherEvidence(ctx, projects) if err != nil { return Summary{}, err diff --git a/internal/scan/scan_test.go b/internal/scan/scan_test.go index 24d6dfd..a7e5158 100644 --- a/internal/scan/scan_test.go +++ b/internal/scan/scan_test.go @@ -11,6 +11,7 @@ import ( "github.com/kryft-dev/cdd/internal/config" "github.com/kryft-dev/cdd/internal/git" "github.com/kryft-dev/cdd/internal/history" + "github.com/kryft-dev/cdd/internal/project" "github.com/kryft-dev/cdd/internal/scan" ) @@ -77,6 +78,13 @@ func newHistory(t *testing.T) *history.History { return hist } +// newStore opens an empty project Store in a fresh temp directory. +func newStore(t *testing.T) *project.Store { + t.Helper() + + return project.OpenStore(filepath.Join(t.TempDir(), "projects")) +} + func newConfig(exclude []string) config.Config { return config.Config{Exclude: exclude} } @@ -91,7 +99,7 @@ func TestRun_RepositorySeededFromCommitTime(t *testing.T) { } hist := newHistory(t) - summary, err := scan.Run(context.Background(), []string{root}, newConfig(nil), hist) + summary, err := scan.Run(context.Background(), []string{root}, newConfig(nil), hist, newStore(t)) if err != nil { t.Fatalf("Run: %v", err) } @@ -127,7 +135,7 @@ func TestRun_RepositoryWithoutCommitSeededFromMtime(t *testing.T) { } hist := newHistory(t) - summary, err := scan.Run(context.Background(), []string{root}, newConfig(nil), hist) + summary, err := scan.Run(context.Background(), []string{root}, newConfig(nil), hist, newStore(t)) if err != nil { t.Fatalf("Run: %v", err) } @@ -161,7 +169,7 @@ func TestRun_AlreadyJumpedProjectNotOverwritten(t *testing.T) { t.Fatalf("Latest: %v", err) } - summary, err := scan.Run(context.Background(), []string{root}, newConfig(nil), hist) + summary, err := scan.Run(context.Background(), []string{root}, newConfig(nil), hist, newStore(t)) if err != nil { t.Fatalf("Run: %v", err) } @@ -185,11 +193,11 @@ func TestRun_SecondRunSeedsNothingNew(t *testing.T) { hist := newHistory(t) cfg := newConfig(nil) - if _, err := scan.Run(context.Background(), []string{root}, cfg, hist); err != nil { + if _, err := scan.Run(context.Background(), []string{root}, cfg, hist, newStore(t)); err != nil { t.Fatalf("first Run: %v", err) } - summary, err := scan.Run(context.Background(), []string{root}, cfg, hist) + summary, err := scan.Run(context.Background(), []string{root}, cfg, hist, newStore(t)) if err != nil { t.Fatalf("second Run: %v", err) } @@ -204,7 +212,7 @@ func TestRun_ExcludedProjectSkipped(t *testing.T) { newProjectDir(t, root, "tools/scratch") hist := newHistory(t) - summary, err := scan.Run(context.Background(), []string{root}, newConfig([]string{"scratch"}), hist) + summary, err := scan.Run(context.Background(), []string{root}, newConfig([]string{"scratch"}), hist, newStore(t)) if err != nil { t.Fatalf("Run: %v", err) } @@ -227,7 +235,7 @@ func TestRun_PlainDirectoryNotSeeded(t *testing.T) { t.Fatalf("mkdir: %v", err) } - summary, err := scan.Run(context.Background(), []string{root}, newConfig(nil), newHistory(t)) + summary, err := scan.Run(context.Background(), []string{root}, newConfig(nil), newHistory(t), newStore(t)) if err != nil { t.Fatalf("Run: %v", err) } @@ -235,3 +243,52 @@ func TestRun_PlainDirectoryNotSeeded(t *testing.T) { t.Fatalf("summary = %+v, want nothing seeded", summary) } } + +func TestRun_ForgottenProjectNotResurrected(t *testing.T) { + root := t.TempDir() + gone := newProjectDir(t, root, "tools/forgotten") + kept := newProjectDir(t, root, "tools/kept") + + hist := newHistory(t) + store := newStore(t) + if err := store.Forget(gone); err != nil { + t.Fatalf("Forget: %v", err) + } + + summary, err := scan.Run(context.Background(), []string{root}, newConfig(nil), hist, store) + if err != nil { + t.Fatalf("Run: %v", err) + } + if summary != (scan.Summary{Seeded: 1, Projects: 1}) { + t.Fatalf("summary = %+v, want {Seeded:1 Projects:1}", summary) + } + + visits, err := hist.Latest() + if err != nil { + t.Fatalf("Latest: %v", err) + } + if len(visits) != 1 || visits[0].Project != kept { + t.Fatalf("visits = %+v, want only %q", visits, kept) + } +} + +func TestRun_ReAddedProjectIsSeededAgain(t *testing.T) { + root := t.TempDir() + dir := newProjectDir(t, root, "tools/back") + + store := newStore(t) + if err := store.Forget(dir); err != nil { + t.Fatalf("Forget: %v", err) + } + if err := store.Add(dir); err != nil { + t.Fatalf("Add: %v", err) + } + + summary, err := scan.Run(context.Background(), []string{root}, newConfig(nil), newHistory(t), store) + if err != nil { + t.Fatalf("Run: %v", err) + } + if summary.Seeded != 1 { + t.Fatalf("Seeded = %d, want 1", summary.Seeded) + } +}