root.go

  1package cmd
  2
  3import (
  4	"bytes"
  5	"context"
  6	_ "embed"
  7	"errors"
  8	"fmt"
  9	"io"
 10	"io/fs"
 11	"log/slog"
 12	"net/url"
 13	"os"
 14	"os/exec"
 15	"path/filepath"
 16	"regexp"
 17	"strconv"
 18	"strings"
 19	"time"
 20
 21	tea "charm.land/bubbletea/v2"
 22	fang "charm.land/fang/v2"
 23	"charm.land/lipgloss/v2"
 24	"github.com/charmbracelet/colorprofile"
 25	"github.com/charmbracelet/crush/internal/app"
 26	"github.com/charmbracelet/crush/internal/client"
 27	"github.com/charmbracelet/crush/internal/config"
 28	"github.com/charmbracelet/crush/internal/db"
 29	"github.com/charmbracelet/crush/internal/event"
 30	crushlog "github.com/charmbracelet/crush/internal/log"
 31	"github.com/charmbracelet/crush/internal/projects"
 32	"github.com/charmbracelet/crush/internal/proto"
 33	"github.com/charmbracelet/crush/internal/server"
 34	"github.com/charmbracelet/crush/internal/session"
 35	"github.com/charmbracelet/crush/internal/ui/common"
 36	ui "github.com/charmbracelet/crush/internal/ui/model"
 37	"github.com/charmbracelet/crush/internal/version"
 38	"github.com/charmbracelet/crush/internal/workspace"
 39	uv "github.com/charmbracelet/ultraviolet"
 40	"github.com/charmbracelet/x/ansi"
 41	"github.com/charmbracelet/x/exp/charmtone"
 42	"github.com/charmbracelet/x/term"
 43	"github.com/spf13/cobra"
 44)
 45
 46var clientHost string
 47
 48func init() {
 49	rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
 50	rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
 51	rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
 52	rootCmd.PersistentFlags().StringVarP(&clientHost, "host", "H", server.DefaultHost(), "Connect to a specific crush server host (for advanced users)")
 53	rootCmd.Flags().BoolP("help", "h", false, "Help")
 54	rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
 55	rootCmd.Flags().StringP("session", "s", "", "Continue a previous session by ID")
 56	rootCmd.Flags().BoolP("continue", "C", false, "Continue the most recent session")
 57	rootCmd.MarkFlagsMutuallyExclusive("session", "continue")
 58
 59	rootCmd.AddCommand(
 60		runCmd,
 61		dirsCmd,
 62		projectsCmd,
 63		updateProvidersCmd,
 64		logsCmd,
 65		schemaCmd,
 66		loginCmd,
 67		statsCmd,
 68		sessionCmd,
 69	)
 70}
 71
 72var rootCmd = &cobra.Command{
 73	Use:   "crush",
 74	Short: "A terminal-first AI assistant for software development",
 75	Long:  "A glamorous, terminal-first AI assistant for software development and adjacent tasks",
 76	Example: `
 77# Run in interactive mode
 78crush
 79
 80# Run non-interactively
 81crush run "Guess my 5 favorite PokΓ©mon"
 82
 83# Run a non-interactively with pipes and redirection
 84cat README.md | crush run "make this more glamorous" > GLAMOROUS_README.md
 85
 86# Run with debug logging in a specific directory
 87crush --debug --cwd /path/to/project
 88
 89# Run in yolo mode (auto-accept all permissions; use with care)
 90crush --yolo
 91
 92# Run with custom data directory
 93crush --data-dir /path/to/custom/.crush
 94
 95# Continue a previous session
 96crush --session {session-id}
 97
 98# Continue the most recent session
 99crush --continue
100  `,
101	RunE: func(cmd *cobra.Command, args []string) error {
102		sessionID, _ := cmd.Flags().GetString("session")
103		continueLast, _ := cmd.Flags().GetBool("continue")
104
105		ws, cleanup, err := setupWorkspaceWithProgressBar(cmd)
106		if err != nil {
107			return err
108		}
109		defer cleanup()
110
111		if sessionID != "" {
112			sess, err := resolveWorkspaceSessionID(cmd.Context(), ws, sessionID)
113			if err != nil {
114				return err
115			}
116			sessionID = sess.ID
117		}
118
119		event.AppInitialized()
120
121		com := common.DefaultCommon(ws)
122		model := ui.New(com, sessionID, continueLast)
123
124		var env uv.Environ = os.Environ()
125		program := tea.NewProgram(
126			model,
127			tea.WithEnvironment(env),
128			tea.WithContext(cmd.Context()),
129			tea.WithFilter(ui.MouseEventFilter),
130		)
131		go ws.Subscribe(program)
132
133		if _, err := program.Run(); err != nil {
134			event.Error(err)
135			slog.Error("TUI run error", "error", err)
136			return errors.New("Crush crashed. If metrics are enabled, we were notified about it. If you'd like to report it, please copy the stacktrace above and open an issue at https://github.com/charmbracelet/crush/issues/new?template=bug.yml") //nolint:staticcheck
137		}
138		return nil
139	},
140}
141
142var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
143    β–„β–„β–„β–„β–„β–„β–„β–„    β–„β–„β–„β–„β–„β–„β–„β–„
144  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
145β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
146β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
147β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
148β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
149β–€β–€β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–„β–ˆβ–ˆβ–ˆβ–ˆβ–„β–„β–ˆβ–ˆβ–ˆβ–ˆβ–„β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€β–€
150  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
151    β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
152       β–€β–€β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€β–€
153           β–€β–€β–€β–€β–€β–€
154`)
155
156// copied from cobra:
157const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
158`
159
160func Execute() {
161	// FIXME: config.Load uses slog internally during provider resolution,
162	// but the file-based logger isn't set up until after config is loaded
163	// (because the log path depends on the data directory from config).
164	// This creates a window where slog calls in config.Load leak to
165	// stderr. We discard early logs here as a workaround. The proper
166	// fix is to remove slog calls from config.Load and have it return
167	// warnings/diagnostics instead of logging them as a side effect.
168	slog.SetDefault(slog.New(slog.DiscardHandler))
169
170	// NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
171	// it forward to a bytes.Buffer, write the colored heartbit to it, and then
172	// finally prepend it in the version template.
173	// Unfortunately cobra doesn't give us a way to set a function to handle
174	// printing the version, and PreRunE runs after the version is already
175	// handled, so that doesn't work either.
176	// This is the only way I could find that works relatively well.
177	if term.IsTerminal(os.Stdout.Fd()) {
178		var b bytes.Buffer
179		w := colorprofile.NewWriter(os.Stdout, os.Environ())
180		w.Forward = &b
181		_, _ = w.WriteString(heartbit.String())
182		rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
183	}
184	if err := fang.Execute(
185		context.Background(),
186		rootCmd,
187		fang.WithVersion(version.Version),
188		fang.WithNotifySignal(os.Interrupt),
189	); err != nil {
190		os.Exit(1)
191	}
192}
193
194// supportsProgressBar tries to determine whether the current terminal supports
195// progress bars by looking into environment variables.
196func supportsProgressBar() bool {
197	if !term.IsTerminal(os.Stderr.Fd()) {
198		return false
199	}
200	termProg := os.Getenv("TERM_PROGRAM")
201	_, isWindowsTerminal := os.LookupEnv("WT_SESSION")
202
203	return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
204}
205
206// useClientServer returns true when the client/server architecture is
207// enabled via the CRUSH_CLIENT_SERVER environment variable.
208func useClientServer() bool {
209	v, _ := strconv.ParseBool(os.Getenv("CRUSH_CLIENT_SERVER"))
210	return v
211}
212
213// setupWorkspaceWithProgressBar wraps setupWorkspace with an optional
214// terminal progress bar shown during initialization.
215func setupWorkspaceWithProgressBar(cmd *cobra.Command) (workspace.Workspace, func(), error) {
216	showProgress := supportsProgressBar()
217	if showProgress {
218		_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
219	}
220
221	ws, cleanup, err := setupWorkspace(cmd)
222
223	if showProgress {
224		_, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
225	}
226
227	return ws, cleanup, err
228}
229
230// setupWorkspace returns a Workspace and cleanup function. When
231// CRUSH_CLIENT_SERVER=1, it connects to a server process and returns a
232// ClientWorkspace. Otherwise it creates an in-process app.App and
233// returns an AppWorkspace.
234func setupWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
235	if useClientServer() {
236		return setupClientServerWorkspace(cmd)
237	}
238	return setupLocalWorkspace(cmd)
239}
240
241// setupLocalWorkspace creates an in-process app.App and wraps it in an
242// AppWorkspace.
243func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
244	debug, _ := cmd.Flags().GetBool("debug")
245	yolo, _ := cmd.Flags().GetBool("yolo")
246	dataDir, _ := cmd.Flags().GetString("data-dir")
247	ctx := cmd.Context()
248
249	cwd, err := ResolveCwd(cmd)
250	if err != nil {
251		return nil, nil, err
252	}
253
254	store, err := config.Init(cwd, dataDir, debug)
255	if err != nil {
256		return nil, nil, err
257	}
258
259	cfg := store.Config()
260	store.Overrides().SkipPermissionRequests = yolo
261
262	if err := os.MkdirAll(cfg.Options.DataDirectory, 0o700); err != nil {
263		return nil, nil, fmt.Errorf("failed to create data directory: %q %w", cfg.Options.DataDirectory, err)
264	}
265
266	gitIgnorePath := filepath.Join(cfg.Options.DataDirectory, ".gitignore")
267	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
268		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
269			return nil, nil, fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
270		}
271	}
272
273	if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
274		slog.Warn("Failed to register project", "error", err)
275	}
276
277	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
278	if err != nil {
279		return nil, nil, err
280	}
281
282	logFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
283	crushlog.Setup(logFile, debug)
284
285	appInstance, err := app.New(ctx, conn, store)
286	if err != nil {
287		_ = conn.Close()
288		slog.Error("Failed to create app instance", "error", err)
289		return nil, nil, err
290	}
291
292	if shouldEnableMetrics(cfg) {
293		event.Init()
294	}
295
296	ws := workspace.NewAppWorkspace(appInstance, store)
297	cleanup := func() { appInstance.Shutdown() }
298	return ws, cleanup, nil
299}
300
301// setupClientServerWorkspace connects to a server process and wraps the
302// result in a ClientWorkspace.
303func setupClientServerWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
304	c, protoWs, cleanupServer, err := connectToServer(cmd)
305	if err != nil {
306		return nil, nil, err
307	}
308
309	clientWs := workspace.NewClientWorkspace(c, *protoWs)
310
311	if protoWs.Config.IsConfigured() {
312		if err := clientWs.InitCoderAgent(cmd.Context()); err != nil {
313			slog.Error("Failed to initialize coder agent", "error", err)
314		}
315	}
316
317	return clientWs, cleanupServer, nil
318}
319
320// connectToServer ensures the server is running, creates a client and
321// workspace, and returns a cleanup function that deletes the workspace.
322func connectToServer(cmd *cobra.Command) (*client.Client, *proto.Workspace, func(), error) {
323	hostURL, err := server.ParseHostURL(clientHost)
324	if err != nil {
325		return nil, nil, nil, fmt.Errorf("invalid host URL: %v", err)
326	}
327
328	if err := ensureServer(cmd, hostURL); err != nil {
329		return nil, nil, nil, err
330	}
331
332	debug, _ := cmd.Flags().GetBool("debug")
333	yolo, _ := cmd.Flags().GetBool("yolo")
334	dataDir, _ := cmd.Flags().GetString("data-dir")
335	ctx := cmd.Context()
336
337	cwd, err := ResolveCwd(cmd)
338	if err != nil {
339		return nil, nil, nil, err
340	}
341
342	c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
343	if err != nil {
344		return nil, nil, nil, err
345	}
346
347	wsReq := proto.Workspace{
348		Path:    cwd,
349		DataDir: dataDir,
350		Debug:   debug,
351		YOLO:    yolo,
352		Version: version.Version,
353		Env:     os.Environ(),
354	}
355
356	ws, err := c.CreateWorkspace(ctx, wsReq)
357	if err != nil {
358		// The server socket may exist before the HTTP handler is ready.
359		// Retry a few times with a short backoff.
360		for range 5 {
361			select {
362			case <-ctx.Done():
363				return nil, nil, nil, ctx.Err()
364			case <-time.After(200 * time.Millisecond):
365			}
366			ws, err = c.CreateWorkspace(ctx, wsReq)
367			if err == nil {
368				break
369			}
370		}
371		if err != nil {
372			return nil, nil, nil, fmt.Errorf("failed to create workspace: %v", err)
373		}
374	}
375
376	if shouldEnableMetrics(ws.Config) {
377		event.Init()
378	}
379
380	if ws.Config != nil {
381		logFile := filepath.Join(ws.Config.Options.DataDirectory, "logs", "crush.log")
382		crushlog.Setup(logFile, debug)
383	}
384
385	cleanup := func() { _ = c.DeleteWorkspace(context.Background(), ws.ID) }
386	return c, ws, cleanup, nil
387}
388
389// ensureServer auto-starts a detached server if the socket file does not
390// exist. When the socket exists, it verifies that the running server
391// version matches the client; on mismatch it shuts down the old server
392// and starts a fresh one.
393func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
394	switch hostURL.Scheme {
395	case "unix", "npipe":
396		needsStart := false
397		if _, err := os.Stat(hostURL.Host); err != nil && errors.Is(err, fs.ErrNotExist) {
398			needsStart = true
399		} else if err == nil {
400			if err := restartIfStale(cmd, hostURL); err != nil {
401				slog.Warn("Failed to check server version, restarting", "error", err)
402				needsStart = true
403			}
404		}
405
406		if needsStart {
407			if err := startDetachedServer(cmd); err != nil {
408				return err
409			}
410		}
411
412		var err error
413		for range 10 {
414			_, err = os.Stat(hostURL.Host)
415			if err == nil {
416				break
417			}
418			select {
419			case <-cmd.Context().Done():
420				return cmd.Context().Err()
421			case <-time.After(100 * time.Millisecond):
422			}
423		}
424		if err != nil {
425			return fmt.Errorf("failed to initialize crush server: %v", err)
426		}
427	}
428
429	return nil
430}
431
432// restartIfStale checks whether the running server matches the current
433// client version. When they differ, it sends a shutdown command and
434// removes the stale socket so the caller can start a fresh server.
435func restartIfStale(cmd *cobra.Command, hostURL *url.URL) error {
436	c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
437	if err != nil {
438		return err
439	}
440	vi, err := c.VersionInfo(cmd.Context())
441	if err != nil {
442		return err
443	}
444	if vi.Version == version.Version {
445		return nil
446	}
447	slog.Info("Server version mismatch, restarting",
448		"server", vi.Version,
449		"client", version.Version,
450	)
451	_ = c.ShutdownServer(cmd.Context())
452	// Give the old process a moment to release the socket.
453	for range 20 {
454		if _, err := os.Stat(hostURL.Host); errors.Is(err, fs.ErrNotExist) {
455			break
456		}
457		select {
458		case <-cmd.Context().Done():
459			return cmd.Context().Err()
460		case <-time.After(100 * time.Millisecond):
461		}
462	}
463	// Force-remove if the socket is still lingering.
464	_ = os.Remove(hostURL.Host)
465	return nil
466}
467
468var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
469
470func startDetachedServer(cmd *cobra.Command) error {
471	exe, err := os.Executable()
472	if err != nil {
473		return fmt.Errorf("failed to get executable path: %v", err)
474	}
475
476	safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
477	chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
478	if err := os.MkdirAll(chDir, 0o700); err != nil {
479		return fmt.Errorf("failed to create server working directory: %v", err)
480	}
481
482	cmdArgs := []string{"server"}
483	if clientHost != server.DefaultHost() {
484		cmdArgs = append(cmdArgs, "--host", clientHost)
485	}
486
487	c := exec.CommandContext(cmd.Context(), exe, cmdArgs...)
488	stdoutPath := filepath.Join(chDir, "stdout.log")
489	stderrPath := filepath.Join(chDir, "stderr.log")
490	detachProcess(c)
491
492	stdout, err := os.Create(stdoutPath)
493	if err != nil {
494		return fmt.Errorf("failed to create stdout log file: %v", err)
495	}
496	defer stdout.Close()
497	c.Stdout = stdout
498
499	stderr, err := os.Create(stderrPath)
500	if err != nil {
501		return fmt.Errorf("failed to create stderr log file: %v", err)
502	}
503	defer stderr.Close()
504	c.Stderr = stderr
505
506	if err := c.Start(); err != nil {
507		return fmt.Errorf("failed to start crush server: %v", err)
508	}
509
510	if err := c.Process.Release(); err != nil {
511		return fmt.Errorf("failed to detach crush server process: %v", err)
512	}
513
514	return nil
515}
516
517func shouldEnableMetrics(cfg *config.Config) bool {
518	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
519		return false
520	}
521	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
522		return false
523	}
524	if cfg.Options.DisableMetrics {
525		return false
526	}
527	return true
528}
529
530func MaybePrependStdin(prompt string) (string, error) {
531	if term.IsTerminal(os.Stdin.Fd()) {
532		return prompt, nil
533	}
534	fi, err := os.Stdin.Stat()
535	if err != nil {
536		return prompt, err
537	}
538	// Check if stdin is a named pipe ( | ) or regular file ( < ).
539	if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
540		return prompt, nil
541	}
542	bts, err := io.ReadAll(os.Stdin)
543	if err != nil {
544		return prompt, err
545	}
546	return string(bts) + "\n\n" + prompt, nil
547}
548
549// resolveWorkspaceSessionID resolves a session ID that may be a full
550// UUID, full hash, or hash prefix. Works against the Workspace
551// interface so both local and client/server paths get hash prefix
552// support.
553func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
554	if sess, err := ws.GetSession(ctx, id); err == nil {
555		return sess, nil
556	}
557
558	sessions, err := ws.ListSessions(ctx)
559	if err != nil {
560		return session.Session{}, err
561	}
562
563	var matches []session.Session
564	for _, s := range sessions {
565		hash := session.HashID(s.ID)
566		if hash == id || strings.HasPrefix(hash, id) {
567			matches = append(matches, s)
568		}
569	}
570
571	switch len(matches) {
572	case 0:
573		return session.Session{}, fmt.Errorf("session not found: %s", id)
574	case 1:
575		return matches[0], nil
576	default:
577		return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
578	}
579}
580
581func ResolveCwd(cmd *cobra.Command) (string, error) {
582	cwd, _ := cmd.Flags().GetString("cwd")
583	if cwd != "" {
584		err := os.Chdir(cwd)
585		if err != nil {
586			return "", fmt.Errorf("failed to change directory: %v", err)
587		}
588		return cwd, nil
589	}
590	cwd, err := os.Getwd()
591	if err != nil {
592		return "", fmt.Errorf("failed to get current working directory: %v", err)
593	}
594	return cwd, nil
595}
596
597func createDotCrushDir(dir string) error {
598	if err := os.MkdirAll(dir, 0o700); err != nil {
599		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
600	}
601
602	gitIgnorePath := filepath.Join(dir, ".gitignore")
603	content, err := os.ReadFile(gitIgnorePath)
604
605	// create or update if old version
606	if os.IsNotExist(err) || string(content) == oldGitIgnore {
607		if err := os.WriteFile(gitIgnorePath, []byte(defaultGitIgnore), 0o644); err != nil {
608			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
609		}
610	}
611
612	return nil
613}
614
615//go:embed gitignore/old
616var oldGitIgnore string
617
618//go:embed gitignore/default
619var defaultGitIgnore string