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