From 2b139cfd0b85770e81f886af847672675c15fbd1 Mon Sep 17 00:00:00 2001 From: Robert Laszczak Date: Sun, 26 Apr 2026 12:41:54 +0200 Subject: [PATCH 1/8] fix: detection of git on Windows --- internal/terminal.go | 21 +++++++++++++++---- internal/version.go | 15 +++++++++++++ tdl/main.go | 4 +--- trainings/info.go | 50 ++++++++++++++++++++++++++++++++++++-------- trainings/init.go | 25 ++++++++++++---------- 5 files changed, 88 insertions(+), 27 deletions(-) create mode 100644 internal/version.go diff --git a/internal/terminal.go b/internal/terminal.go index e4f466b..8c65cd2 100644 --- a/internal/terminal.go +++ b/internal/terminal.go @@ -2,6 +2,7 @@ package internal import ( "bufio" + "fmt" "io" "os" "strconv" @@ -14,14 +15,26 @@ import ( const stdinFileDescriptor = 0 const stdoutFileDescriptor = 1 -// IsStdinTerminal returns true if stdin is connected to a terminal (not a pipe or file). -func IsStdinTerminal() bool { +func stdinTerminalReason() (bool, string) { if v, _ := strconv.ParseBool(os.Getenv("TDL_FORCE_INTERACTIVE")); v { - return true + return true, "true (TDL_FORCE_INTERACTIVE)" + } + if terminal.IsTerminal(stdinFileDescriptor) { + return true, "true (native console)" + } + // On Windows, mintty (Git Bash) and MSYS2 terminals use pipes instead of + // native console handles, so IsTerminal returns false even though they fully + // support ANSI/VT sequences. The TERM env var is a reliable signal for these. + if term := os.Getenv("TERM"); term != "" { + return true, fmt.Sprintf("true (TERM=%s)", term) } - return terminal.IsTerminal(stdinFileDescriptor) + return false, "false" } +// IsStdinTerminal returns true if stdin is connected to a terminal (not a pipe or file). +func IsStdinTerminal() bool { v, _ := stdinTerminalReason(); return v } +func IsStdinTerminalReason() string { _, s := stdinTerminalReason(); return s } + // NewRawTerminalReader returns raw terminal reader which allows reading stdin without hitting enter. func NewRawTerminalReader(stdin io.Reader) (*bufio.Reader, func(), error) { if stdin != os.Stdin { diff --git a/internal/version.go b/internal/version.go new file mode 100644 index 0000000..25325d8 --- /dev/null +++ b/internal/version.go @@ -0,0 +1,15 @@ +package internal + +import ( + "runtime/debug" + "strings" +) + +// BinaryVersion returns the resolved binary version. It reads from build info +// (set by go install / goreleaser), falling back to "dev" for local builds. +func BinaryVersion() string { + if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" && bi.Main.Version != "(devel)" { + return strings.TrimPrefix(bi.Main.Version, "v") + } + return "dev" +} diff --git a/tdl/main.go b/tdl/main.go index 58c3993..584c289 100644 --- a/tdl/main.go +++ b/tdl/main.go @@ -47,9 +47,7 @@ func main() { }() if version == "" || version == "dev" { - if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" && bi.Main.Version != "(devel)" { - version = strings.TrimPrefix(bi.Main.Version, "v") - } + version = internal.BinaryVersion() } ctx, cancel := context.WithCancel(context.Background()) diff --git a/trainings/info.go b/trainings/info.go index 43f3581..897e4f5 100644 --- a/trainings/info.go +++ b/trainings/info.go @@ -3,14 +3,25 @@ package trainings import ( "context" "fmt" + "os" + "os/exec" + "runtime" + "strings" "github.com/fatih/color" "github.com/pkg/errors" "github.com/ThreeDotsLabs/cli/internal" "github.com/ThreeDotsLabs/cli/trainings/config" + "github.com/ThreeDotsLabs/cli/trainings/git" ) +func printInfoSection(name string) { + fmt.Println() + fmt.Println(color.New(color.Bold).Sprint(name)) + fmt.Println(color.HiBlackString(strings.Repeat("─", len(name)))) +} + func (h *Handlers) Info(ctx context.Context) error { trainingRoot, err := h.config.FindTrainingRoot() if errors.Is(err, config.TrainingRootNotFoundError) { @@ -25,21 +36,18 @@ func (h *Handlers) Info(ctx context.Context) error { exerciseConfig := h.config.ExerciseConfig(trainingRootFs) - fmt.Println("### Training") - fmt.Println("Name:", color.CyanString(trainingConfig.TrainingName)) + printInfoSection("Training") + fmt.Println("Name: ", color.CyanString(trainingConfig.TrainingName)) fmt.Println("Root dir:", color.CyanString(trainingRoot)) - fmt.Println() - - fmt.Println("### Current exercise") - fmt.Println("ID:", color.CyanString(exerciseConfig.ExerciseID)) - fmt.Println("Files:", color.CyanString(h.generateRunTerminalPath(trainingRootFs))) + printInfoSection("Current exercise") + fmt.Println("ID: ", color.CyanString(exerciseConfig.ExerciseID)) + fmt.Println("Files: ", color.CyanString(h.generateRunTerminalPath(trainingRootFs))) exerciseURL := internal.ExerciseURL(trainingConfig.TrainingName, exerciseConfig.ExerciseID) fmt.Println("Content:", color.CyanString(exerciseURL)) if trainingConfig.GitConfigured { - fmt.Println() - fmt.Println("### Git") + printInfoSection("Git") if !trainingConfig.GitEnabled { fmt.Println("Status:", color.YellowString("disabled")) } else { @@ -64,5 +72,29 @@ func (h *Handlers) Info(ctx context.Context) error { } } + printInfoSection("Environment") + fmt.Println("CLI version:", color.CyanString(internal.BinaryVersion())) + fmt.Println("OS: ", color.CyanString(runtime.GOOS+"/"+runtime.GOARCH)) + + shell := os.Getenv("SHELL") + if shell == "" { + shell = os.Getenv("COMSPEC") + } + if shell == "" { + shell = "unknown" + } + fmt.Println("Shell: ", color.CyanString(shell)) + fmt.Println("Terminal: ", color.CyanString(internal.IsStdinTerminalReason())) + + if gitPath, err := exec.LookPath("git"); err == nil { + fmt.Println("Git path: ", color.CyanString(gitPath)) + if v, err := git.CheckVersion(); err == nil { + fmt.Println("Git version:", color.CyanString(v.String())) + } + } else { + fmt.Println("Git path: ", color.YellowString("not found in PATH")) + } + + fmt.Println() return nil } diff --git a/trainings/init.go b/trainings/init.go index 8bf7635..8b569b5 100644 --- a/trainings/init.go +++ b/trainings/init.go @@ -53,10 +53,9 @@ func (h *Handlers) Init(ctx context.Context, trainingName string, dir string, no // Partial init: exercise not yet set up, fall through to nextExercise. } - // Git integration: init repo, configure preferences, initial commit - // Skip git entirely in non-interactive mode (pipes, CI, E2E) — we can't prompt for preferences. - // forceGit overrides the non-interactive check (used by E2E tests and scripted restore). - gitOps := git.NewOps(trainingRootDir, noGit || (!forceGit && !internal.IsStdinTerminal())) + // Git integration: init repo, configure preferences, initial commit. + // Always attempt git detection; terminal is only needed for the "git missing" prompt. + gitOps := git.NewOps(trainingRootDir, noGit) gitWasUnavailable := false if gitOps.Enabled() { @@ -65,17 +64,21 @@ func (h *Handlers) Init(ctx context.Context, trainingName string, dir string, no var notInstalled *git.GitNotInstalledError var tooOld *git.GitTooOldError if errors.As(err, ¬Installed) { - printGitUnavailableNotice("Git is not installed.", git.InstallHint(runtime.GOOS)) - if !promptContinueWithoutGit() { - return nil + if internal.IsStdinTerminal() { + printGitUnavailableNotice("Git is not installed.", git.InstallHint(runtime.GOOS)) + if !promptContinueWithoutGit() { + return nil + } } gitOps = git.NewOps(trainingRootDir, true) gitWasUnavailable = true } else if errors.As(err, &tooOld) { - reason := fmt.Sprintf("Your git version (%s) is too old: %s or newer is required.", tooOld.Detected, tooOld.Required) - printGitUnavailableNotice(reason, git.InstallHint(runtime.GOOS)) - if !promptContinueWithoutGit() { - return nil + if internal.IsStdinTerminal() { + reason := fmt.Sprintf("Your git version (%s) is too old: %s or newer is required.", tooOld.Detected, tooOld.Required) + printGitUnavailableNotice(reason, git.InstallHint(runtime.GOOS)) + if !promptContinueWithoutGit() { + return nil + } } gitOps = git.NewOps(trainingRootDir, true) gitWasUnavailable = true From 0d758ae5d0ce10c96756ed257a0534ba2f537a57 Mon Sep 17 00:00:00 2001 From: Robert Laszczak Date: Sun, 26 Apr 2026 17:41:23 +0200 Subject: [PATCH 2/8] better terminal detection on Windows and proper warning if no git detected --- internal/gitnotice.go | 47 ++++++++++++++++++++++++++++++++++++++++++ internal/terminal.go | 27 +++++++++++++++--------- trainings/init.go | 2 ++ trainings/migration.go | 22 ++++++++++++++++++++ trainings/run.go | 6 +++++- 5 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 internal/gitnotice.go diff --git a/internal/gitnotice.go b/internal/gitnotice.go new file mode 100644 index 0000000..72aa5ef --- /dev/null +++ b/internal/gitnotice.go @@ -0,0 +1,47 @@ +package internal + +import ( + "encoding/json" + "os" + "path" + "time" +) + +const gitInstallNoticeCooldown = 24 * time.Hour + +type gitInstallNoticeInfo struct { + LastShown time.Time `json:"last_shown"` +} + +func gitInstallNoticePath() string { + return path.Join(GlobalConfigDir(), "git-install-notice") +} + +func ShouldShowGitInstallNotice() bool { + info, err := readGitInstallNoticeInfo() + if err != nil { + return true + } + return time.Since(info.LastShown) >= gitInstallNoticeCooldown +} + +func RecordGitInstallNoticeShown() error { + info := gitInstallNoticeInfo{LastShown: time.Now()} + data, err := json.Marshal(info) + if err != nil { + return err + } + return os.WriteFile(gitInstallNoticePath(), data, 0644) +} + +func readGitInstallNoticeInfo() (gitInstallNoticeInfo, error) { + data, err := os.ReadFile(gitInstallNoticePath()) + if err != nil { + return gitInstallNoticeInfo{}, err + } + var info gitInstallNoticeInfo + if err := json.Unmarshal(data, &info); err != nil { + return gitInstallNoticeInfo{}, err + } + return info, nil +} diff --git a/internal/terminal.go b/internal/terminal.go index 8c65cd2..8054793 100644 --- a/internal/terminal.go +++ b/internal/terminal.go @@ -9,7 +9,7 @@ import ( "github.com/pkg/errors" "github.com/sirupsen/logrus" - "golang.org/x/crypto/ssh/terminal" + "golang.org/x/term" ) const stdinFileDescriptor = 0 @@ -19,14 +19,21 @@ func stdinTerminalReason() (bool, string) { if v, _ := strconv.ParseBool(os.Getenv("TDL_FORCE_INTERACTIVE")); v { return true, "true (TDL_FORCE_INTERACTIVE)" } - if terminal.IsTerminal(stdinFileDescriptor) { + if term.IsTerminal(stdinFileDescriptor) { return true, "true (native console)" } - // On Windows, mintty (Git Bash) and MSYS2 terminals use pipes instead of - // native console handles, so IsTerminal returns false even though they fully - // support ANSI/VT sequences. The TERM env var is a reliable signal for these. - if term := os.Getenv("TERM"); term != "" { - return true, fmt.Sprintf("true (TERM=%s)", term) + // mintty (Git Bash) and MSYS2 use pipes instead of native console handles; + // TERM env var is their reliable signal. + if t := os.Getenv("TERM"); t != "" { + return true, fmt.Sprintf("true (TERM=%s)", t) + } + // Windows Terminal sets WT_SESSION in every child process. + if wt := os.Getenv("WT_SESSION"); wt != "" { + return true, "true (WT_SESSION)" + } + // VS Code integrated terminal and other modern emulators set TERM_PROGRAM. + if tp := os.Getenv("TERM_PROGRAM"); tp != "" { + return true, fmt.Sprintf("true (TERM_PROGRAM=%s)", tp) } return false, "false" } @@ -42,13 +49,13 @@ func NewRawTerminalReader(stdin io.Reader) (*bufio.Reader, func(), error) { return bufio.NewReader(stdin), func() {}, nil } - state, err := terminal.MakeRaw(stdinFileDescriptor) + state, err := term.MakeRaw(stdinFileDescriptor) if err != nil { return nil, func() {}, errors.Wrap(err, "can't set stdin to raw") } return bufio.NewReader(stdin), func() { - if err := terminal.Restore(stdinFileDescriptor, state); err != nil { + if err := term.Restore(stdinFileDescriptor, state); err != nil { logrus.WithError(err).Warn("Failed to restore terminal") } }, err @@ -61,7 +68,7 @@ func DoNotTrack() bool { // TerminalWidth returns the current terminal width, falling back to 60 if not a TTY. func TerminalWidth() int { - w, _, err := terminal.GetSize(stdoutFileDescriptor) + w, _, err := term.GetSize(stdoutFileDescriptor) if err != nil || w <= 0 { return 60 } diff --git a/trainings/init.go b/trainings/init.go index 8b569b5..f17e4fd 100644 --- a/trainings/init.go +++ b/trainings/init.go @@ -66,6 +66,7 @@ func (h *Handlers) Init(ctx context.Context, trainingName string, dir string, no if errors.As(err, ¬Installed) { if internal.IsStdinTerminal() { printGitUnavailableNotice("Git is not installed.", git.InstallHint(runtime.GOOS)) + _ = internal.RecordGitInstallNoticeShown() if !promptContinueWithoutGit() { return nil } @@ -76,6 +77,7 @@ func (h *Handlers) Init(ctx context.Context, trainingName string, dir string, no if internal.IsStdinTerminal() { reason := fmt.Sprintf("Your git version (%s) is too old: %s or newer is required.", tooOld.Detected, tooOld.Required) printGitUnavailableNotice(reason, git.InstallHint(runtime.GOOS)) + _ = internal.RecordGitInstallNoticeShown() if !promptContinueWithoutGit() { return nil } diff --git a/trainings/migration.go b/trainings/migration.go index e3ac2be..45e871a 100644 --- a/trainings/migration.go +++ b/trainings/migration.go @@ -2,6 +2,7 @@ package trainings import ( "fmt" + "runtime" "strings" "github.com/fatih/color" @@ -80,3 +81,24 @@ func printGitNotices(cfg config.TrainingConfig) { printGitMigrationNotice(cfg) printGitNowAvailableNotice(cfg) } + +// showGitInstallNoticeIfDue shows the "git not installed" notice at most once per 24 h. +// Only fires when the workspace was created without git (GitUnavailable=true) and git +// is still missing. Returns false if the user chose to quit. +func showGitInstallNoticeIfDue(cfg config.TrainingConfig) bool { + if !cfg.GitConfigured || cfg.GitEnabled || !cfg.GitUnavailable { + return true + } + if _, err := git.CheckVersion(); err == nil { + return true // git became available — printGitNowAvailableNotice handles this + } + if !internal.ShouldShowGitInstallNotice() { + return true // shown recently, skip + } + _ = internal.RecordGitInstallNoticeShown() + printGitUnavailableNotice("Git is not installed.", git.InstallHint(runtime.GOOS)) + if !internal.IsStdinTerminal() { + return true + } + return promptContinueWithoutGit() +} diff --git a/trainings/run.go b/trainings/run.go index aa86439..e7cd40b 100644 --- a/trainings/run.go +++ b/trainings/run.go @@ -36,7 +36,11 @@ func (h *Handlers) Run(ctx context.Context, detached bool) error { } trainingRootFs := newTrainingRootFs(trainingRoot) - printGitNotices(h.config.TrainingConfig(trainingRootFs)) + cfg := h.config.TrainingConfig(trainingRootFs) + printGitNotices(cfg) + if !showGitInstallNoticeIfDue(cfg) { + return nil + } agentInstructions, err := h.fetchAgentInstructions(ctx, trainingRootFs) if err != nil { From 0d78ed933c10644f57cc96b79755479a5ca679cf Mon Sep 17 00:00:00 2001 From: Robert Laszczak Date: Sun, 26 Apr 2026 17:57:07 +0200 Subject: [PATCH 3/8] fix terminal detection on Windows --- internal/terminal.go | 8 ++++++-- trainings/migration.go | 9 +++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/internal/terminal.go b/internal/terminal.go index 8054793..1352e68 100644 --- a/internal/terminal.go +++ b/internal/terminal.go @@ -12,8 +12,12 @@ import ( "golang.org/x/term" ) -const stdinFileDescriptor = 0 -const stdoutFileDescriptor = 1 +// On Windows, golang.org/x/term uses fd directly as a Win32 HANDLE. +// os.Stdin.Fd() returns the real Win32 handle (not 0); hardcoding 0 +// would pass NULL to GetConsoleMode and always return "not a terminal". +// On Unix, os.Stdin.Fd() returns 0 as usual — no behaviour change. +var stdinFileDescriptor = int(os.Stdin.Fd()) +var stdoutFileDescriptor = int(os.Stdout.Fd()) func stdinTerminalReason() (bool, string) { if v, _ := strconv.ParseBool(os.Getenv("TDL_FORCE_INTERACTIVE")); v { diff --git a/trainings/migration.go b/trainings/migration.go index 45e871a..a3abf91 100644 --- a/trainings/migration.go +++ b/trainings/migration.go @@ -83,14 +83,15 @@ func printGitNotices(cfg config.TrainingConfig) { } // showGitInstallNoticeIfDue shows the "git not installed" notice at most once per 24 h. -// Only fires when the workspace was created without git (GitUnavailable=true) and git -// is still missing. Returns false if the user chose to quit. +// Fires whenever git is disabled and still not installed — covers both workspaces where +// git was missing at init (GitUnavailable=true) and older workspaces that were silently +// disabled before the terminal-detection fix. Returns false if the user chose to quit. func showGitInstallNoticeIfDue(cfg config.TrainingConfig) bool { - if !cfg.GitConfigured || cfg.GitEnabled || !cfg.GitUnavailable { + if !cfg.GitConfigured || cfg.GitEnabled { return true } if _, err := git.CheckVersion(); err == nil { - return true // git became available — printGitNowAvailableNotice handles this + return true // git is available — printGitNowAvailableNotice handles the reinitialize prompt } if !internal.ShouldShowGitInstallNotice() { return true // shown recently, skip From fe5fe52940e0689fba8e162d7214c9646a07e9e3 Mon Sep 17 00:00:00 2001 From: Robert Laszczak Date: Sun, 26 Apr 2026 18:04:52 +0200 Subject: [PATCH 4/8] windows pls --- trainings/next.go | 7 ++++--- trainings/run.go | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/trainings/next.go b/trainings/next.go index 94bd1aa..35c13f3 100644 --- a/trainings/next.go +++ b/trainings/next.go @@ -30,9 +30,10 @@ func (h *Handlers) promptRune(actions internal.Actions) rune { printPrompt(actions) - termState, rawErr := term.MakeRaw(0) + stdinFd := int(os.Stdin.Fd()) + termState, rawErr := term.MakeRaw(stdinFd) if rawErr == nil { - defer term.Restore(0, termState) + defer term.Restore(stdinFd, termState) } if h.stdinCh == nil { @@ -44,7 +45,7 @@ func (h *Handlers) promptRune(actions internal.Actions) rune { } if string(ch) == "\x03" { if rawErr == nil { - term.Restore(0, termState) + term.Restore(stdinFd, termState) } os.Exit(0) } diff --git a/trainings/run.go b/trainings/run.go index e7cd40b..c77dc47 100644 --- a/trainings/run.go +++ b/trainings/run.go @@ -771,9 +771,10 @@ func (h *Handlers) waitForAction( defer fmt.Println() printPrompt(actions) - termState, rawErr := term.MakeRaw(0) + stdinFd := int(os.Stdin.Fd()) + termState, rawErr := term.MakeRaw(stdinFd) if rawErr == nil { - defer term.Restore(0, termState) + defer term.Restore(stdinFd, termState) } drainChannel(h.stdinCh) @@ -783,7 +784,7 @@ func (h *Handlers) waitForAction( case ch := <-h.stdinCh: if string(ch) == "\x03" { if rawErr == nil { - term.Restore(0, termState) + term.Restore(stdinFd, termState) } os.Exit(0) } From 24363cc5566e01a68fb1ec0318290ca165f582e1 Mon Sep 17 00:00:00 2001 From: Robert Laszczak Date: Sun, 26 Apr 2026 18:07:51 +0200 Subject: [PATCH 5/8] better git instructions for windows --- trainings/git/version.go | 2 ++ trainings/migration.go | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/trainings/git/version.go b/trainings/git/version.go index 4780cd0..04e1c28 100644 --- a/trainings/git/version.go +++ b/trainings/git/version.go @@ -100,6 +100,8 @@ func InstallHint(goos string) string { " winget install Git.Git", "", "Or download from https://git-scm.com/downloads", + "", + "IMPORTANT: After installing, open a new terminal window for git to be available.", }, "\n") default: return "Install or upgrade git from https://git-scm.com/downloads" diff --git a/trainings/migration.go b/trainings/migration.go index a3abf91..44142a0 100644 --- a/trainings/migration.go +++ b/trainings/migration.go @@ -44,11 +44,11 @@ func printGitMigrationNotice(cfg config.TrainingConfig) { fmt.Println() } -// printGitNowAvailableNotice shows a banner when git has become available -// since the workspace was created without it (git was missing/too old). -// Does not trigger for users who chose --no-git (GitUnavailable = false). +// printGitNowAvailableNotice shows a banner when git is available but the workspace +// has git disabled. Covers both workspaces where git was missing at init (GitUnavailable=true) +// and older workspaces that were silently disabled before the terminal-detection fix. func printGitNowAvailableNotice(cfg config.TrainingConfig) { - if !cfg.GitConfigured || cfg.GitEnabled || !cfg.GitUnavailable { + if !cfg.GitConfigured || cfg.GitEnabled { return } @@ -64,7 +64,7 @@ func printGitNowAvailableNotice(cfg config.TrainingConfig) { fmt.Println(sep) fmt.Println(title) fmt.Println() - fmt.Println(" Git was not available when this workspace was created.") + fmt.Println(" Git was not enabled for this workspace.") fmt.Println(" You can enable git integration by reinitializing:") fmt.Println() fmt.Printf(" cd ..\n") From 9327a51bbf264c3e1417671bca1d289a77357eec Mon Sep 17 00:00:00 2001 From: Robert Laszczak Date: Sun, 26 Apr 2026 18:14:32 +0200 Subject: [PATCH 6/8] use custom author and mail for git commits --- trainings/git/git.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/trainings/git/git.go b/trainings/git/git.go index 618de2d..7cd57dc 100644 --- a/trainings/git/git.go +++ b/trainings/git/git.go @@ -29,6 +29,22 @@ import ( "github.com/sirupsen/logrus" ) +const ( + AuthorName = "Three Dots Labs" + AuthorEmail = "contact@threedotslabs.com" +) + +// authorEnv returns the environment variables that set the git author and committer +// identity for all CLI-driven git operations. +func authorEnv() []string { + return []string{ + "GIT_AUTHOR_NAME=" + AuthorName, + "GIT_AUTHOR_EMAIL=" + AuthorEmail, + "GIT_COMMITTER_NAME=" + AuthorName, + "GIT_COMMITTER_EMAIL=" + AuthorEmail, + } +} + // Ops provides git operations for the training CLI. // All methods are no-ops when enabled is false. // @@ -84,6 +100,7 @@ func (g *Ops) PrintInfo(display string) { func (g *Ops) run(args ...string) (string, error) { cmd := exec.Command("git", args...) cmd.Dir = g.rootDir + cmd.Env = append(os.Environ(), authorEnv()...) logrus.WithFields(logrus.Fields{ "args": args, @@ -253,6 +270,7 @@ func (g *Ops) CommitAllowEmptyWithDate(msg string, date time.Time) error { "GIT_AUTHOR_DATE="+dateStr, "GIT_COMMITTER_DATE="+dateStr, ) + cmd.Env = append(cmd.Env, authorEnv()...) logrus.WithFields(logrus.Fields{ "args": []string{"commit", "--allow-empty", "-m", msg}, @@ -286,6 +304,7 @@ func (g *Ops) CommitWithDate(msg string, date time.Time) error { "GIT_AUTHOR_DATE="+dateStr, "GIT_COMMITTER_DATE="+dateStr, ) + cmd.Env = append(cmd.Env, authorEnv()...) logrus.WithFields(logrus.Fields{ "args": []string{"commit", "-m", msg}, From a04ab0f457f67286dd8ef2b03921c3965bccd168 Mon Sep 17 00:00:00 2001 From: Robert Laszczak Date: Sun, 26 Apr 2026 18:15:40 +0200 Subject: [PATCH 7/8] add notice for git availability and limit to once per 24 hours --- trainings/migration.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/trainings/migration.go b/trainings/migration.go index 44142a0..4fa9f79 100644 --- a/trainings/migration.go +++ b/trainings/migration.go @@ -44,8 +44,8 @@ func printGitMigrationNotice(cfg config.TrainingConfig) { fmt.Println() } -// printGitNowAvailableNotice shows a banner when git is available but the workspace -// has git disabled. Covers both workspaces where git was missing at init (GitUnavailable=true) +// printGitNowAvailableNotice shows a banner (at most once per 24 h) when git is available +// but the workspace has git disabled. Covers both workspaces where git was missing at init // and older workspaces that were silently disabled before the terminal-detection fix. func printGitNowAvailableNotice(cfg config.TrainingConfig) { if !cfg.GitConfigured || cfg.GitEnabled { @@ -57,6 +57,11 @@ func printGitNowAvailableNotice(cfg config.TrainingConfig) { return } + if !internal.ShouldShowGitInstallNotice() { + return + } + _ = internal.RecordGitInstallNoticeShown() + sep := color.HiBlackString(strings.Repeat("─", internal.TerminalWidth())) title := color.New(color.Bold, color.FgHiGreen).Sprint(" *** Git is now available! ***") initCmd := color.CyanString("tdl training init %s .", cfg.TrainingName) From 3a181767e428c98008539f44b2738cce829e93e5 Mon Sep 17 00:00:00 2001 From: Robert Laszczak Date: Sun, 26 Apr 2026 18:47:58 +0200 Subject: [PATCH 8/8] add prompt for user action during migration process --- trainings/migration.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/trainings/migration.go b/trainings/migration.go index 4fa9f79..b73f802 100644 --- a/trainings/migration.go +++ b/trainings/migration.go @@ -2,6 +2,7 @@ package trainings import ( "fmt" + "os" "runtime" "strings" @@ -79,6 +80,21 @@ func printGitNowAvailableNotice(cfg config.TrainingConfig) { fmt.Println(" Your progress will be restored automatically.") fmt.Println(sep) fmt.Println() + + if internal.IsStdinTerminal() { + choice := internal.Prompt( + internal.Actions{ + {Shortcut: '\n', Action: "continue for now", ShortcutAliases: []rune{'\r'}}, + {Shortcut: 'q', Action: "quit to reinitialize"}, + }, + os.Stdin, + os.Stdout, + ) + fmt.Println() + if choice == 'q' { + os.Exit(0) + } + } } // printGitNotices shows all relevant git migration/availability notices.