diff --git a/CLAUDE.md b/CLAUDE.md index 0ddbc9c..d28bbb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,9 +57,12 @@ The `cheat` command-line tool is organized into several key packages: ### Command Layer (`cmd/cheat/`) - `main.go`: Entry point, cobra command definition, flag registration, command routing - `cmd_*.go`: Individual command implementations (view, edit, list, search, etc.) -- `completions.go`: Dynamic shell completion functions for cheatsheet names, tags, and paths - Commands are routed via a `switch` block in the cobra `RunE` handler +### Completions (`internal/completions/`) +- Dynamic shell completion functions for cheatsheet names, tags, and cheatpath names +- `generate.go`: Generates completion scripts for bash, zsh, fish, and powershell + ### Core Internal Packages 1. **`internal/config`**: Configuration management diff --git a/HACKING.md b/HACKING.md index 9635444..b82aa15 100644 --- a/HACKING.md +++ b/HACKING.md @@ -213,7 +213,7 @@ The codebase follows consistent error handling patterns: Example: ```go -sheet, err := sheet.New(path, tags, false) +s, err := sheet.New(title, cheatpath, path, tags, false) if err != nil { return fmt.Errorf("failed to load sheet: %w", err) } diff --git a/INSTALLING.md b/INSTALLING.md index 9988656..5d2e3d1 100644 --- a/INSTALLING.md +++ b/INSTALLING.md @@ -24,7 +24,7 @@ On Windows, download the appropriate binary from the [releases page][releases], unzip the archive, and place the `cheat.exe` executable on your `PATH`. ## Install via `go install` -If you have `go` version `>=1.17` available on your `PATH`, you can install +If you have `go` version `>=1.26` available on your `PATH`, you can install `cheat` via `go install`: ```sh diff --git a/adr/002-environment-variable-parsing.md b/adr/002-environment-variable-parsing.md index d764307..9d870ef 100644 --- a/adr/002-environment-variable-parsing.md +++ b/adr/002-environment-variable-parsing.md @@ -8,7 +8,7 @@ Accepted ## Context -In the `envVars()` function in `cmd/cheat/main.go`, the code parses environment variables assuming they all contain an equals sign: +In the `EnvVars()` function in `internal/config/env.go`, the code parses environment variables assuming they all contain an equals sign: ```go for _, e := range os.Environ() { diff --git a/adr/004-recursive-cheat-directory-search.md b/adr/004-recursive-cheat-directory-search.md index c152408..fb1088b 100644 --- a/adr/004-recursive-cheat-directory-search.md +++ b/adr/004-recursive-cheat-directory-search.md @@ -77,4 +77,4 @@ multiple cheatpaths can configure them in `conf.yml`. ## References - GitHub issue: #602 -- Implementation: `findLocalCheatpath()` in `internal/config/config.go` +- Implementation: `findLocalCheatpath()` in `internal/config/new.go` diff --git a/cmd/cheat/cmd_update.go b/cmd/cheat/cmd_update.go new file mode 100644 index 0000000..c5258a8 --- /dev/null +++ b/cmd/cheat/cmd_update.go @@ -0,0 +1,42 @@ +package main + +import ( + "errors" + "fmt" + "os" + + "github.com/go-git/go-git/v5" + "github.com/spf13/cobra" + + "github.com/cheat/cheat/internal/config" + "github.com/cheat/cheat/internal/repo" +) + +// cmdUpdate updates git-backed cheatpaths. +func cmdUpdate(_ *cobra.Command, _ []string, conf config.Config) { + + hasError := false + + for _, path := range conf.Cheatpaths { + err := repo.Pull(path.Path) + + switch { + case err == nil: + fmt.Printf("%s: ok\n", path.Name) + + case errors.Is(err, git.ErrRepositoryNotExists): + fmt.Printf("%s: skipped (not a git repository)\n", path.Name) + + case errors.Is(err, repo.ErrDirtyWorktree): + fmt.Printf("%s: skipped (dirty worktree)\n", path.Name) + + default: + fmt.Fprintf(os.Stderr, "%s: error (%v)\n", path.Name, err) + hasError = true + } + } + + if hasError { + os.Exit(1) + } +} diff --git a/cmd/cheat/main.go b/cmd/cheat/main.go index 939053a..3822615 100755 --- a/cmd/cheat/main.go +++ b/cmd/cheat/main.go @@ -15,7 +15,7 @@ import ( "github.com/cheat/cheat/internal/installer" ) -const version = "5.0.0" +const version = "5.1.0" var rootCmd = &cobra.Command{ Use: "cheat [cheatsheet]", @@ -60,6 +60,9 @@ remember.`, To remove (delete) the foo/bar cheatsheet: cheat --rm foo/bar + To update all git-backed cheatpaths: + cheat --update + To view the configuration file path: cheat --conf @@ -87,6 +90,7 @@ func init() { f.BoolP("list", "l", false, "List cheatsheets") f.BoolP("regex", "r", false, "Treat search as a regex") f.BoolP("tags", "T", false, "List all tags in use") + f.BoolP("update", "u", false, "Update git-backed cheatpaths") f.BoolP("version", "v", false, "Print the version number") f.Bool("conf", false, "Display the config file path") @@ -221,6 +225,7 @@ func run(cmd *cobra.Command, args []string) error { listFlag, _ := f.GetBool("list") briefFlag, _ := f.GetBool("brief") tagsFlag, _ := f.GetBool("tags") + updateFlag, _ := f.GetBool("update") tagVal, _ := f.GetString("tag") switch { @@ -239,6 +244,9 @@ func run(cmd *cobra.Command, args []string) error { case tagsFlag: cmdTags(cmd, args, conf) + case updateFlag: + cmdUpdate(cmd, args, conf) + case f.Changed("search"): cmdSearch(cmd, args, conf) diff --git a/doc/cheat.1 b/doc/cheat.1 index 9723531..5dc32b1 100644 --- a/doc/cheat.1 +++ b/doc/cheat.1 @@ -53,6 +53,9 @@ Filter only to sheets tagged with \f[I]TAG\f[R]. \-T, \[en]tags List all tags in use. .TP +\-u, \[en]update +Update git\-backed cheatpaths by pulling the latest changes. +.TP \-v, \[en]version Print the version number. .TP @@ -98,23 +101,31 @@ cheat \-c \-r \-s \f[I]`(?:[0\-9]{1,3}.){3}[0\-9]{1,3}'\f[R] To remove (delete) the foo/bar cheatsheet: cheat \[en]rm \f[I]foo/bar\f[R] .TP +To update all git\-backed cheatpaths: +cheat \[en]update +.TP +To update only the `community' cheatpath: +cheat \-u \-p \f[I]community\f[R] +.TP To view the configuration file path: cheat \[en]conf .SH FILES .SS Configuration \f[B]cheat\f[R] is configured via a YAML file that is conventionally -named \f[I]conf.yaml\f[R]. -\f[B]cheat\f[R] will search for \f[I]conf.yaml\f[R] in varying -locations, depending upon your platform: +named \f[I]conf.yml\f[R]. +\f[B]cheat\f[R] will search for \f[I]conf.yml\f[R] in varying locations, +depending upon your platform: .SS Linux, OSX, and other Unixes .IP "1." 3 \f[B]CHEAT_CONFIG_PATH\f[R] .IP "2." 3 -\f[B]XDG_CONFIG_HOME\f[R]/cheat/conf.yaml +\f[B]XDG_CONFIG_HOME\f[R]/cheat/conf.yml .IP "3." 3 \f[B]$HOME\f[R]/.config/cheat/conf.yml .IP "4." 3 \f[B]$HOME\f[R]/.cheat/conf.yml +.IP "5." 3 +/etc/cheat/conf.yml .SS Windows .IP "1." 3 \f[B]CHEAT_CONFIG_PATH\f[R] @@ -124,7 +135,7 @@ locations, depending upon your platform: \f[B]PROGRAMDATA\f[R]/cheat/conf.yml .PP \f[B]cheat\f[R] will search in the order specified above. -The first \f[I]conf.yaml\f[R] encountered will be respected. +The first \f[I]conf.yml\f[R] encountered will be respected. .PP If \f[B]cheat\f[R] cannot locate a config file, it will ask if you\[cq]d like to generate one automatically. @@ -134,7 +145,7 @@ location for your platform. .SS Cheatpaths \f[B]cheat\f[R] reads its cheatsheets from \[lq]cheatpaths\[rq], which are the directories in which cheatsheets are stored. -Cheatpaths may be configured in \f[I]conf.yaml\f[R], and viewed via +Cheatpaths may be configured in \f[I]conf.yml\f[R], and viewed via \f[B]cheat \-d\f[R]. .PP For detailed instructions on how to configure cheatpaths, please refer diff --git a/doc/cheat.1.md b/doc/cheat.1.md index 8acadef..2e6f8fb 100644 --- a/doc/cheat.1.md +++ b/doc/cheat.1.md @@ -59,6 +59,9 @@ OPTIONS -T, --tags : List all tags in use. +-u, --update +: Update git-backed cheatpaths by pulling the latest changes. + -v, --version : Print the version number. @@ -106,6 +109,12 @@ To search (by regex) for cheatsheets that contain an IP address: To remove (delete) the foo/bar cheatsheet: : cheat --rm _foo/bar_ +To update all git-backed cheatpaths: +: cheat --update + +To update only the 'community' cheatpath: +: cheat -u -p _community_ + To view the configuration file path: : cheat --conf @@ -116,15 +125,16 @@ FILES Configuration ------------- **cheat** is configured via a YAML file that is conventionally named -_conf.yaml_. **cheat** will search for _conf.yaml_ in varying locations, +_conf.yml_. **cheat** will search for _conf.yml_ in varying locations, depending upon your platform: ### Linux, OSX, and other Unixes ### 1. **CHEAT_CONFIG_PATH** -2. **XDG_CONFIG_HOME**/cheat/conf.yaml +2. **XDG_CONFIG_HOME**/cheat/conf.yml 3. **$HOME**/.config/cheat/conf.yml 4. **$HOME**/.cheat/conf.yml +5. /etc/cheat/conf.yml ### Windows ### @@ -132,7 +142,7 @@ depending upon your platform: 2. **APPDATA**/cheat/conf.yml 3. **PROGRAMDATA**/cheat/conf.yml -**cheat** will search in the order specified above. The first _conf.yaml_ +**cheat** will search in the order specified above. The first _conf.yml_ encountered will be respected. If **cheat** cannot locate a config file, it will ask if you'd like to generate @@ -144,7 +154,7 @@ for your platform. Cheatpaths ---------- **cheat** reads its cheatsheets from "cheatpaths", which are the directories in -which cheatsheets are stored. Cheatpaths may be configured in _conf.yaml_, and +which cheatsheets are stored. Cheatpaths may be configured in _conf.yml_, and viewed via **cheat -d**. For detailed instructions on how to configure cheatpaths, please refer to the diff --git a/internal/repo/pull.go b/internal/repo/pull.go new file mode 100644 index 0000000..794f55b --- /dev/null +++ b/internal/repo/pull.go @@ -0,0 +1,130 @@ +package repo + +import ( + "errors" + "os" + "path/filepath" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/transport" + gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh" + "github.com/mitchellh/go-homedir" +) + +// ErrDirtyWorktree indicates that the worktree has uncommitted changes. +var ErrDirtyWorktree = errors.New("dirty worktree") + +// Pull performs a git pull on the repository at path. It returns +// ErrDirtyWorktree if the worktree has uncommitted changes, and +// git.ErrRepositoryNotExists if path is not a git repository. +func Pull(path string) error { + + // open the repository + r, err := git.PlainOpen(path) + if err != nil { + return err + } + + // get the worktree + wt, err := r.Worktree() + if err != nil { + return err + } + + // check if the worktree is clean + status, err := wt.Status() + if err != nil { + return err + } + if !status.IsClean() { + return ErrDirtyWorktree + } + + // build pull options, using SSH auth when the remote is SSH + opts := &git.PullOptions{} + if auth, err := sshAuth(r); err == nil && auth != nil { + opts.Auth = auth + } + + // pull + err = wt.Pull(opts) + if errors.Is(err, git.NoErrAlreadyUpToDate) { + return nil + } + + return err +} + +// defaultKeyFiles are the SSH key filenames tried in order, matching the +// default behavior of OpenSSH. +var defaultKeyFiles = []string{ + "id_rsa", + "id_ecdsa", + "id_ecdsa_sk", + "id_ed25519", + "id_ed25519_sk", + "id_dsa", +} + +// sshAuth returns an appropriate SSH auth method if the origin remote uses +// the SSH protocol, or nil if it does not. It tries the SSH agent first, then +// falls back to default key files in ~/.ssh/. +func sshAuth(r *git.Repository) (transport.AuthMethod, error) { + remote, err := r.Remote("origin") + if err != nil { + return nil, err + } + + urls := remote.Config().URLs + if len(urls) == 0 { + return nil, nil + } + + ep, err := transport.NewEndpoint(urls[0]) + if err != nil { + return nil, err + } + + if ep.Protocol != "ssh" { + return nil, nil + } + + user := ep.User + if user == "" { + user = "git" + } + + // try default key files first — this is more reliable than the SSH + // agent, which may report success even when no keys are loaded + home, err := homedir.Dir() + if err == nil { + if auth := findKeyFile(filepath.Join(home, ".ssh"), user); auth != nil { + return auth, nil + } + } + + // fall back to SSH agent + if auth, err := gitssh.NewSSHAgentAuth(user); err == nil { + return auth, nil + } + + return nil, nil +} + +// findKeyFile looks for a usable SSH private key in sshDir, trying the +// standard OpenSSH default filenames in order. Returns nil if no usable key +// is found. +func findKeyFile(sshDir, user string) transport.AuthMethod { + for _, name := range defaultKeyFiles { + keyPath := filepath.Join(sshDir, name) + if _, err := os.Stat(keyPath); err != nil { + continue + } + auth, err := gitssh.NewPublicKeysFromFile(user, keyPath, "") + if err != nil { + continue + } + return auth + } + return nil +} diff --git a/internal/repo/pull_test.go b/internal/repo/pull_test.go new file mode 100644 index 0000000..56fe402 --- /dev/null +++ b/internal/repo/pull_test.go @@ -0,0 +1,315 @@ +package repo + +import ( + "crypto/ed25519" + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" + "testing" + "time" + + "github.com/go-git/go-git/v5" + gitconfig "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// testCommitOpts returns a CommitOptions suitable for test commits. +func testCommitOpts() *git.CommitOptions { + return &git.CommitOptions{ + Author: &object.Signature{ + Name: "test", + Email: "test@test", + When: time.Now(), + }, + } +} + +// initBareRepoWithCommit creates a bare git repository at dir with an initial +// commit, suitable for use as a remote. +func initBareRepoWithCommit(t *testing.T, dir string) { + t.Helper() + + // init a non-bare repo to make the commit, then we'll clone it as bare + tmpWork := t.TempDir() + r, err := git.PlainInit(tmpWork, false) + if err != nil { + t.Fatalf("failed to init repo: %v", err) + } + + f := filepath.Join(tmpWork, "README") + if err := os.WriteFile(f, []byte("hello\n"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + wt, err := r.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + + if _, err := wt.Add("README"); err != nil { + t.Fatalf("failed to stage file: %v", err) + } + + if _, err = wt.Commit("initial commit", testCommitOpts()); err != nil { + t.Fatalf("failed to commit: %v", err) + } + + // clone as bare into the target dir + if _, err = git.PlainClone(dir, true, &git.CloneOptions{URL: tmpWork}); err != nil { + t.Fatalf("failed to create bare clone: %v", err) + } +} + +// cloneLocal clones the bare repo at bareDir into a new working directory and +// returns the path. +func cloneLocal(t *testing.T, bareDir string) string { + t.Helper() + + dir := t.TempDir() + _, err := git.PlainClone(dir, false, &git.CloneOptions{ + URL: bareDir, + }) + if err != nil { + t.Fatalf("failed to clone: %v", err) + } + + return dir +} + +// pushNewCommit clones bareDir into a temporary working copy, commits a new +// file, and pushes back to the bare repo. +func pushNewCommit(t *testing.T, bareDir string) { + t.Helper() + + tmpWork := t.TempDir() + r, err := git.PlainClone(tmpWork, false, &git.CloneOptions{URL: bareDir}) + if err != nil { + t.Fatalf("failed to clone for push: %v", err) + } + + if err := os.WriteFile(filepath.Join(tmpWork, "new.txt"), []byte("new\n"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + wt, err := r.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + if _, err := wt.Add("new.txt"); err != nil { + t.Fatalf("failed to stage file: %v", err) + } + if _, err := wt.Commit("add new file", testCommitOpts()); err != nil { + t.Fatalf("failed to commit: %v", err) + } + if err := r.Push(&git.PushOptions{}); err != nil { + t.Fatalf("failed to push: %v", err) + } +} + +// generateTestKey creates an unencrypted ed25519 PEM private key file at path. +func generateTestKey(t *testing.T, path string) { + t.Helper() + + _, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + + der, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + t.Fatalf("failed to marshal key: %v", err) + } + + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + if err := os.WriteFile(path, pemBytes, 0600); err != nil { + t.Fatalf("failed to write key file: %v", err) + } +} + +// --- Pull tests --- + +func TestPull_NotARepo(t *testing.T) { + dir := t.TempDir() + + err := Pull(dir) + if err != git.ErrRepositoryNotExists { + t.Fatalf("expected ErrRepositoryNotExists, got: %v", err) + } +} + +func TestPull_CleanAlreadyUpToDate(t *testing.T) { + bare := t.TempDir() + initBareRepoWithCommit(t, bare) + clone := cloneLocal(t, bare) + + err := Pull(clone) + if err != nil { + t.Fatalf("expected nil (already up-to-date), got: %v", err) + } +} + +func TestPull_NewUpstreamChanges(t *testing.T) { + bare := t.TempDir() + initBareRepoWithCommit(t, bare) + clone := cloneLocal(t, bare) + + // push a new commit to the bare repo after the clone + pushNewCommit(t, bare) + + err := Pull(clone) + if err != nil { + t.Fatalf("expected nil (successful pull), got: %v", err) + } + + // verify the new file was pulled + if _, err := os.Stat(filepath.Join(clone, "new.txt")); err != nil { + t.Fatalf("expected new.txt to exist after pull: %v", err) + } +} + +func TestPull_DirtyWorktree(t *testing.T) { + bare := t.TempDir() + initBareRepoWithCommit(t, bare) + clone := cloneLocal(t, bare) + + // make the worktree dirty with a modified tracked file + if err := os.WriteFile(filepath.Join(clone, "README"), []byte("changed\n"), 0644); err != nil { + t.Fatalf("failed to modify file: %v", err) + } + + err := Pull(clone) + if err != ErrDirtyWorktree { + t.Fatalf("expected ErrDirtyWorktree, got: %v", err) + } +} + +func TestPull_DirtyWorktreeUntracked(t *testing.T) { + bare := t.TempDir() + initBareRepoWithCommit(t, bare) + clone := cloneLocal(t, bare) + + // make the worktree dirty with an untracked file + if err := os.WriteFile(filepath.Join(clone, "untracked.txt"), []byte("new\n"), 0644); err != nil { + t.Fatalf("failed to create file: %v", err) + } + + err := Pull(clone) + if err != ErrDirtyWorktree { + t.Fatalf("expected ErrDirtyWorktree, got: %v", err) + } +} + +// --- sshAuth tests --- + +func TestSshAuth_NonSSHRemote(t *testing.T) { + bare := t.TempDir() + initBareRepoWithCommit(t, bare) + clone := cloneLocal(t, bare) + + r, err := git.PlainOpen(clone) + if err != nil { + t.Fatalf("failed to open repo: %v", err) + } + + // the clone's origin is a local file:// path, not SSH + auth, err := sshAuth(r) + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } + if auth != nil { + t.Fatalf("expected nil auth for non-SSH remote, got: %v", auth) + } +} + +func TestSshAuth_NoRemote(t *testing.T) { + dir := t.TempDir() + r, err := git.PlainInit(dir, false) + if err != nil { + t.Fatalf("failed to init repo: %v", err) + } + + // repo has no remotes + auth, err := sshAuth(r) + if err == nil { + t.Fatalf("expected error for missing remote, got auth: %v", auth) + } +} + +func TestSshAuth_SSHRemote(t *testing.T) { + dir := t.TempDir() + r, err := git.PlainInit(dir, false) + if err != nil { + t.Fatalf("failed to init repo: %v", err) + } + + // add an SSH remote + _, err = r.CreateRemote(&gitconfig.RemoteConfig{ + Name: "origin", + URLs: []string{"git@github.com:example/repo.git"}, + }) + if err != nil { + t.Fatalf("failed to create remote: %v", err) + } + + // sshAuth should not return an error — even if no key is found, it + // returns (nil, nil) rather than an error + auth, err := sshAuth(r) + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } + + // we can't predict whether auth is nil or non-nil here because it + // depends on whether the test runner has SSH keys or an agent; just + // verify it didn't error + _ = auth +} + +// --- findKeyFile tests --- + +func TestFindKeyFile_ValidKey(t *testing.T) { + sshDir := t.TempDir() + generateTestKey(t, filepath.Join(sshDir, "id_ed25519")) + + auth := findKeyFile(sshDir, "git") + if auth == nil { + t.Fatal("expected non-nil auth for valid key file") + } +} + +func TestFindKeyFile_NoKeys(t *testing.T) { + sshDir := t.TempDir() + + auth := findKeyFile(sshDir, "git") + if auth != nil { + t.Fatalf("expected nil auth for empty directory, got: %v", auth) + } +} + +func TestFindKeyFile_InvalidKey(t *testing.T) { + sshDir := t.TempDir() + // write garbage into a file named like a key + if err := os.WriteFile(filepath.Join(sshDir, "id_ed25519"), []byte("not a key"), 0600); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + auth := findKeyFile(sshDir, "git") + if auth != nil { + t.Fatalf("expected nil auth for invalid key file, got: %v", auth) + } +} + +func TestFindKeyFile_SkipsInvalidFindsValid(t *testing.T) { + sshDir := t.TempDir() + + // put garbage in id_rsa (tried first), valid key in id_ed25519 (tried later) + if err := os.WriteFile(filepath.Join(sshDir, "id_rsa"), []byte("not a key"), 0600); err != nil { + t.Fatalf("failed to write file: %v", err) + } + generateTestKey(t, filepath.Join(sshDir, "id_ed25519")) + + auth := findKeyFile(sshDir, "git") + if auth == nil { + t.Fatal("expected non-nil auth; should skip invalid id_rsa and find id_ed25519") + } +}