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