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