Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,15 +25,15 @@ 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**:
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**:
Expand Down
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <path>` or `forget <path>`, and the last line for a path wins.

## How it works

Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/adr/0001-projects-are-git-repos-found-by-scan.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
20 changes: 20 additions & 0 deletions docs/adr/0002-projects-can-be-added-by-hand.md
Original file line number Diff line number Diff line change
@@ -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 <absolute path>` or `forget <absolute path>`, 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.
8 changes: 5 additions & 3 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down
10 changes: 8 additions & 2 deletions internal/cli/pick.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
}
Expand Down
110 changes: 110 additions & 0 deletions internal/cli/projects.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading