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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,8 @@ visual density (more or fewer seeds, longer or shorter blooms) for clarity.

`git-rain --rain` opens an interactive picker. Repositories stream in as the filesystem scan finds them — no waiting for the full scan to complete before you can start picking. After you confirm, the tool runs the **default full fetch** (`git fetch --all`, prune opt-in) unless you passed **`--fetch-mainline`**, or **full branch hydration** is implied by **`--sync`**, **`--risky`**, **`risky_mode`** in config, a **non-mainline `branch_mode`**, or **any `--branch-mode`** on the CLI. Quitting (**`q`** or **`ctrl+c`**) cancels the in-progress scan (in-flight `git` subprocesses are aborted via the scan context); **`ctrl+c`** outside raw TTY mode is treated like cancel.

Status strip and optional log panel are driven from structured session events. Confirming selection still exits TUI and runs fetch/sync work in normal CLI output.

**Key bindings:**

| Key | Action |
Expand All @@ -377,6 +379,8 @@ visual density (more or fewer seeds, longer or shorter blooms) for clarity.
| `q` / `ctrl+c` | Abort picker |
| `c` / `Esc` | Back from settings (ignored list uses `Esc` / `i` / `b`) |
| `↑` / `↓` | Navigate |
| `Shift+L` | Toggle in-TUI log panel |
| `e` | Export visible TUI log buffer to `~/.cache/git-rain/exports/` |

## Safe Mode vs Risky Mode

Expand Down
7 changes: 7 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/git-rain/git-rain/internal/git"
"github.com/git-rain/git-rain/internal/registry"
"github.com/git-rain/git-rain/internal/safety"
"github.com/git-rain/git-rain/internal/sessionlog"
"github.com/git-rain/git-rain/internal/ui"
)

Expand Down Expand Up @@ -508,6 +509,11 @@ func runRainTUIStream(cfg *config.Config, reg *registry.Registry, regPath string

userCfgDir, _ := config.UserGitRainDir()
cfgPath := filepath.Join(userCfgDir, "config.toml")
logger, err := sessionlog.NewLogger(sessionlog.DefaultLogDir())
if err != nil {
return fmt.Errorf("init rain logger: %w", err)
}
defer func() { _ = logger.Close() }()

selected, err := ui.RunRepoSelectorStream(
tuiRepoChan,
Expand All @@ -518,6 +524,7 @@ func runRainTUIStream(cfg *config.Config, reg *registry.Registry, regPath string
cfgPath,
reg,
regPath,
logger,
)

// Cancel scan first so filepath walk and in-flight git subprocesses unwind.
Expand Down
127 changes: 127 additions & 0 deletions internal/sessionlog/logger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package sessionlog

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"

"github.com/git-rain/git-rain/internal/safety"
)

// LogEntry matches git-fire's structured session event shape.
type LogEntry struct {
Timestamp time.Time `json:"timestamp"`
Level string `json:"level"`
Repo string `json:"repo,omitempty"`
Action string `json:"action"`
Description string `json:"description"`
Error string `json:"error,omitempty"`
Duration string `json:"duration,omitempty"`
}

// EventSubscriber receives structured entries as they are written.
type EventSubscriber func(LogEntry)

// Logger writes JSONL session logs.
type Logger struct {
logPath string
file *os.File
writes int
subscribers []EventSubscriber
}

// NewLogger creates a new rain session logger.
func NewLogger(logDir string) (*Logger, error) {
if err := os.MkdirAll(logDir, 0o700); err != nil {
return nil, fmt.Errorf("create log dir: %w", err)
}
logFilename := fmt.Sprintf("git-rain-%s.log", time.Now().Format("20060102-150405"))
logPath := filepath.Join(logDir, logFilename)
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return nil, fmt.Errorf("create log file: %w", err)
}
logger := &Logger{logPath: logPath, file: file}
logger.Info("", "git-rain-start", "Git-rain session started")
return logger, nil
}

// Subscribe registers a process-local subscriber.
func (l *Logger) Subscribe(fn EventSubscriber) {
if fn == nil {
return
}
l.subscribers = append(l.subscribers, fn)
}

// Log writes one JSONL entry.
func (l *Logger) Log(entry LogEntry) error {
if l.file == nil {
return fmt.Errorf("logger not initialized")
}
entry.Timestamp = time.Now()
data, err := json.Marshal(entry)
if err != nil {
return fmt.Errorf("marshal log entry: %w", err)
}
if _, err := l.file.Write(append(data, '\n')); err != nil {
return fmt.Errorf("write log entry: %w", err)
}
for _, fn := range l.subscribers {
fn(entry)
}
l.writes++
if l.writes%20 == 0 {
return l.file.Sync()
}
return nil
}

// Info writes a level=info entry.
func (l *Logger) Info(repo, action, description string) {
_ = l.Log(LogEntry{Level: "info", Repo: repo, Action: action, Description: description})
}

// Error writes a level=error entry with redaction.
func (l *Logger) Error(repo, action, description string, err error) {
e := LogEntry{Level: "error", Repo: repo, Action: action, Description: description}
if err != nil {
e.Error = safety.SanitizeText(err.Error())
}
_ = l.Log(e)
}

// Success writes a level=success entry.
func (l *Logger) Success(repo, action, description string, duration time.Duration) {
_ = l.Log(LogEntry{
Level: "success",
Repo: repo,
Action: action,
Description: description,
Duration: duration.String(),
})
}

// Close writes end marker and closes file.
func (l *Logger) Close() error {
if l.file == nil {
return nil
}
l.Info("", "git-rain-end", "Git-rain session ended")
_ = l.file.Sync()
return l.file.Close()
}

// LogPath returns current file path.
func (l *Logger) LogPath() string { return l.logPath }

// DefaultLogDir resolves standard cache path for rain logs.
func DefaultLogDir() string {
base, err := os.UserCacheDir()
if err != nil {
return filepath.Join(os.TempDir(), "git-rain", "logs")
}
return filepath.Join(base, "git-rain", "logs")
}
68 changes: 68 additions & 0 deletions internal/sessionlog/logger_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package sessionlog

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)

func TestDefaultLogDir(t *testing.T) {
dir := DefaultLogDir()
if dir == "" {
t.Fatal("DefaultLogDir() should not be empty")
}
if !strings.Contains(dir, filepath.Join("git-rain", "logs")) {
t.Fatalf("unexpected DefaultLogDir: %s", dir)
}
}

func TestLogger_WritesJSONL(t *testing.T) {
logger, err := NewLogger(t.TempDir())
if err != nil {
t.Fatalf("NewLogger() error = %v", err)
}
logger.Info("repo", "scan", "repo discovered")
logger.Success("repo", "scan-complete", "done", time.Second)
if err := logger.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}

data, err := os.ReadFile(logger.LogPath())
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) < 3 {
t.Fatalf("expected at least 3 log lines, got %d", len(lines))
}
for _, line := range lines {
var entry LogEntry
if err := json.Unmarshal([]byte(line), &entry); err != nil {
t.Fatalf("invalid JSON line %q: %v", line, err)
}
}
}

func TestLogger_Subscribe(t *testing.T) {
logger, err := NewLogger(t.TempDir())
if err != nil {
t.Fatalf("NewLogger() error = %v", err)
}
defer func() { _ = logger.Close() }()

seen := make(chan LogEntry, 1)
logger.Subscribe(func(e LogEntry) { seen <- e })
logger.Info("repo", "scan", "repo discovered")

select {
case entry := <-seen:
if entry.Action != "scan" {
t.Fatalf("entry.Action = %q, want scan", entry.Action)
}
case <-time.After(2 * time.Second):
t.Fatal("did not receive subscribed log event")
}
}
52 changes: 52 additions & 0 deletions internal/ui/repo_selector.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/git-rain/git-rain/internal/config"
"github.com/git-rain/git-rain/internal/git"
"github.com/git-rain/git-rain/internal/registry"
"github.com/git-rain/git-rain/internal/sessionlog"
)

// ErrCancelled is returned by RunRepoSelector when the user cancels the TUI.
Expand Down Expand Up @@ -171,6 +172,13 @@ type RepoSelectorModel struct {
cfgPath string
configCursor int
configSaveErr error

logger *sessionlog.Logger
showLogPanel bool
statusLine string
statusIcon string
logEntries []sessionlog.LogEntry
logExportPath string
}

// NewRepoSelectorModel creates a new repo selector model (static mode).
Expand Down Expand Up @@ -208,6 +216,8 @@ func NewRepoSelectorModel(repos []git.Repository, reg *registry.Registry, regPat
currentStartupQuote: randomStartupRainQuote(),
startupQuoteVisible: true,
quoteTickActive: true,
statusLine: "selector ready",
statusIcon: "ℹ️",
}
}

Expand Down Expand Up @@ -286,6 +296,8 @@ func NewRepoSelectorModelStream(
currentStartupQuote: randomStartupRainQuote(),
startupQuoteVisible: showStartupQuote,
quoteTickActive: showStartupQuote && startupQuoteIntervalSec > 0,
statusLine: "scan starting",
statusIcon: "🔍",
}
}

Expand All @@ -303,6 +315,24 @@ func (m RepoSelectorModel) Init() tea.Cmd {
return tea.Batch(cmds...)
}

func (m *RepoSelectorModel) recordStatus(level, action, description string) {
entry := sessionlog.LogEntry{
Timestamp: time.Now(),
Level: level,
Action: action,
Description: description,
}
m.statusIcon = statusGlyph(entry)
m.statusLine = description
m.logEntries = append(m.logEntries, entry)
if len(m.logEntries) > 200 {
m.logEntries = m.logEntries[len(m.logEntries)-200:]
}
if m.logger != nil {
_ = m.logger.Log(entry)
}
}

func (m RepoSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd

Expand All @@ -321,15 +351,18 @@ func (m RepoSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.scanChan != nil {
cmds = append(cmds, waitForRepo(m.scanChan))
}
m.recordStatus("info", "scan-repo", fmt.Sprintf("discovered %s", repo.Name))

case scanProgressMsg:
m.scanCurrentPath = string(msg)
if m.progressChan != nil && !m.progDone {
cmds = append(cmds, waitForProgress(m.progressChan))
}
m.recordStatus("info", "scan-progress", fmt.Sprintf("scanning %s", m.scanCurrentPath))

case repoChanDoneMsg:
m.scanDone = true
m.recordStatus("success", "scan-complete", "scan complete")

case progressChanDoneMsg:
m.progDone = true
Expand Down Expand Up @@ -479,6 +512,23 @@ func (m RepoSelectorModel) updateMainView(msg tea.KeyMsg, cmds []tea.Cmd) (tea.M
m = m.saveConfig()
}

case "L":
m.showLogPanel = !m.showLogPanel
if m.showLogPanel {
m.recordStatus("info", "log-panel-open", "log panel opened")
} else {
m.recordStatus("info", "log-panel-close", "log panel closed")
}

case "e":
path, err := exportLogEntriesText(m.logEntries)
if err != nil {
m.recordStatus("error", "log-export-failed", err.Error())
} else {
m.logExportPath = path
m.recordStatus("success", "log-exported", fmt.Sprintf("exported log to %s", path))
}

case "i":
m.ignoredEntries = IgnoredRegistryEntries(m.reg)
m.ignoredCursor = 0
Expand Down Expand Up @@ -1000,8 +1050,10 @@ func RunRepoSelectorStream(
cfgPath string,
reg *registry.Registry,
regPath string,
logger *sessionlog.Logger,
) ([]git.Repository, error) {
model := NewRepoSelectorModelStream(scanChan, progressChan, scanDisabled, scanDisabledRunOnly, cfg, cfgPath, reg, regPath)
model.logger = logger
p := tea.NewProgram(model, tea.WithAltScreen())

finalModel, err := p.Run()
Expand Down
Loading
Loading