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
10 changes: 8 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,11 @@ jobs:
with:
version: 2026.8.5

- name: Run CI checks
run: mise run ci
- name: Run fixers
run: mise run fixers

- name: Run tests
run: mise run ci-tests

- name: Run govulncheck
run: mise run lint-govulncheck
124 changes: 5 additions & 119 deletions command.go
Original file line number Diff line number Diff line change
@@ -1,126 +1,12 @@
package main

import (
"context"
"fmt"
import "slices"

"github.com/spf13/cobra"
)

func (a *app) command() *cobra.Command {
root := &cobra.Command{
Use: "roam",
Short: "Manage dotfiles with a bare Git repository",
Long: "Use roam like git, with extra commands for a home-directory work tree.",
SilenceUsage: true,
SilenceErrors: true,
DisableFlagParsing: true,
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 || isHelpArg(args) {
return cmd.Help()
}
return a.runGit(cmd.Context(), args...)
},
}
root.CompletionOptions.DisableDefaultCmd = true
root.SetHelpCommand(&cobra.Command{Use: "no-help", Hidden: true})
root.AddCommand(
a.setupCommand(),
a.updateCommand(),
a.syncCommand(),
a.generateCommand(),
)
return root
}
var helpArgs = []string{"-h", "--help"}

func isHelpArg(args []string) bool {
return len(args) == 1 && (args[0] == "-h" || args[0] == "--help")
}

func (a *app) setupCommand() *cobra.Command {
repo := a.defaultRepo()
branch := defaultBranch
cmd := &cobra.Command{
Use: "setup",
Short: "Initialize the dotfiles repository",
Args: cobra.NoArgs,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return a.setup(cmd.Context(), repo, branch)
},
if len(args) != 1 {
return false
}
cmd.Flags().StringVarP(&repo, "repo", "r", repo, "repo name")
cmd.Flags().StringVarP(&branch, "branch", "b", branch, "branch name")
return cmd
}

func (a *app) updateCommand() *cobra.Command {
return &cobra.Command{
Use: "update",
Short: "Fetch and rebase with autostash",
Args: cobra.NoArgs,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return a.update(cmd.Context())
},
}
}

func (a *app) syncCommand() *cobra.Command {
return &cobra.Command{
Use: "sync",
Short: "Update and push",
Args: cobra.NoArgs,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return a.sync(cmd.Context())
},
}
}

func (a *app) generateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "generate",
Short: "Generate shell completion files",
SilenceUsage: true,
SilenceErrors: true,
Args: func(_ *cobra.Command, args []string) error {
if len(args) == 0 {
return nil
}
return fmt.Errorf("unknown generator: %s", args[0])
},
RunE: func(cmd *cobra.Command, _ []string) error {
return cmd.Help()
},
}
var force bool
out := a.defaultZshOut()
zsh := &cobra.Command{
Use: "zsh",
Short: "Generate zsh completion for roam",
Args: cobra.NoArgs,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return a.generateZsh(force, a.expandOut(out))
},
}
zsh.Flags().BoolVarP(&force, "force", "f", false, "Overwrite existing generated files")
zsh.Flags().StringVarP(&out, "out", "o", out, "Output directory for generated files (~/.local/share/zsh/site-functions)")
cmd.AddCommand(zsh)
return cmd
}

func (a *app) run(ctx context.Context, args []string) error {
cmd := a.command()
cmd.SetArgs(args)
cmd.SetIn(a.stdin)
cmd.SetOut(a.stdout)
cmd.SetErr(a.stderr)
return cmd.ExecuteContext(ctx)
return slices.Contains(helpArgs, args[0])
}
82 changes: 82 additions & 0 deletions e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package main

import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/require"
)

func TestSetupEndToEnd(t *testing.T) {
git, err := exec.LookPath("git")
if err != nil {
t.Skip("git not found in PATH")
}
home := t.TempDir()
origin := t.TempDir()
config := fmt.Sprintf(`[user]
name = Tester
email = tester@example.com
[url %q]
insteadOf = git@github.com:tester/dotfiles.git
`, origin)
configPath := filepath.Join(t.TempDir(), "gitconfig")
require.NoError(t, os.WriteFile(configPath, []byte(config), 0o644))
unsetGitEnv(t)
t.Setenv("GIT_CONFIG_GLOBAL", configPath)
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")

runGit := func(args ...string) {
t.Helper()
cmd := exec.Command(git, args...)
cmd.Dir = origin
out, err := cmd.CombinedOutput()
require.NoError(t, err, string(out))
}
runGit("init", "--initial-branch", "master")
require.NoError(t, os.WriteFile(filepath.Join(origin, ".testrc"), []byte("hello\n"), 0o644))
runGit("add", ".testrc")
runGit("commit", "-m", "Add .testrc")

app := &app{
home: home,
git: git,
user: "tester",
stdin: bytes.NewReader(nil),
stdout: io.Discard,
stderr: io.Discard,
}
require.NoError(t, app.run(t.Context(), []string{"setup"}))

got, err := os.ReadFile(filepath.Join(home, ".testrc"))
require.NoError(t, err)
require.Equal(t, "hello\n", string(got))

var stdout bytes.Buffer
app.stdout = &stdout
require.NoError(t, app.run(t.Context(), []string{"status", "--short"}))
require.Empty(t, stdout.String())
}

// unsetGitEnv removes all GIT_* environment variables so git invocations in
// the test are not polluted by the developer's environment, for example when
// the tests run from a hook or during an interactive rebase, which exports
// variables such as GIT_DIR and GIT_INDEX_FILE.
func unsetGitEnv(t *testing.T) {
t.Helper()
for _, kv := range os.Environ() {
name, _, ok := strings.Cut(kv, "=")
if !ok || !strings.HasPrefix(name, "GIT_") {
continue
}
// t.Setenv registers restoration on cleanup and forbids t.Parallel.
t.Setenv(name, "")
require.NoError(t, os.Unsetenv(name))
}
}
36 changes: 0 additions & 36 deletions generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,7 @@ package main

import (
_ "embed"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)

//go:embed _roam
var zshCompletion []byte

func (a *app) defaultZshOut() string {
return filepath.Join(a.home, ".local/share/zsh/site-functions")
}

func (a *app) generateZsh(force bool, out string) error {
if err := os.MkdirAll(out, 0o755); err != nil {
return err
}
path := filepath.Join(out, "_roam")
_, err := os.Stat(path)
switch {
case err == nil:
if !force {
return fmt.Errorf("%s already exists; use --force to overwrite", path)
}
case !errors.Is(err, os.ErrNotExist):
return err
}
return os.WriteFile(path, zshCompletion, 0o644)
}

func (a *app) expandOut(out string) string {
if out == "~" {
return a.home
}
if rest, ok := strings.CutPrefix(out, "~/"); ok {
return filepath.Join(a.home, rest)
}
return out
}
Loading