Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions internal/gitnotice.go
Original file line number Diff line number Diff line change
@@ -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
}
44 changes: 34 additions & 10 deletions internal/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,64 @@ package internal

import (
"bufio"
"fmt"
"io"
"os"
"strconv"

"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh/terminal"
"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())

// 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 term.IsTerminal(stdinFileDescriptor) {
return true, "true (native console)"
}
// 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)"
}
return terminal.IsTerminal(stdinFileDescriptor)
// 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"
}

// 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 {
logrus.Info("Mock mode, returning mock reader")
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
Expand All @@ -48,7 +72,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
}
Expand Down
15 changes: 15 additions & 0 deletions internal/version.go
Original file line number Diff line number Diff line change
@@ -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"
}
4 changes: 1 addition & 3 deletions tdl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
19 changes: 19 additions & 0 deletions trainings/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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},
Expand Down
2 changes: 2 additions & 0 deletions trainings/git/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
50 changes: 41 additions & 9 deletions trainings/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 {
Expand All @@ -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
}
27 changes: 16 additions & 11 deletions trainings/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -65,17 +64,23 @@ func (h *Handlers) Init(ctx context.Context, trainingName string, dir string, no
var notInstalled *git.GitNotInstalledError
var tooOld *git.GitTooOldError
if errors.As(err, &notInstalled) {
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))
_ = internal.RecordGitInstallNoticeShown()
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))
_ = internal.RecordGitInstallNoticeShown()
if !promptContinueWithoutGit() {
return nil
}
}
gitOps = git.NewOps(trainingRootDir, true)
gitWasUnavailable = true
Expand Down
Loading