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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Windows no longer floods the desktop with console windows. lich's Windows
binary runs in the GUI subsystem (no console of its own), so every console
tool it shells out to — `git`, `gh`, `claude` — spawned a fresh console window
per call; with git status polled every few seconds per session and the PR
lookup firing on every window focus, the screen filled with windows and the
machine became unusable. Child console processes are now created with
`CREATE_NO_WINDOW` (`internal/winexec`), a no-op on every other OS.
- The session card and footer diff badge showed `+0 −0` in a repository with no
commits, even though the review panel rendered the full diff: `git diff
--numstat HEAD` errors without a HEAD and skipped the untracked-file count. It
Expand Down
6 changes: 5 additions & 1 deletion internal/claudeplugin/claudeplugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import (
"strconv"
"strings"
"time"

"github.com/omartelo/lich/internal/winexec"
)

const (
Expand Down Expand Up @@ -116,7 +118,9 @@ func (s *Service) runClaude(args ...string) error {
}
ctx, cancel := context.WithTimeout(context.Background(), cmdTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, bin, args...).CombinedOutput()
cmd := exec.CommandContext(ctx, bin, args...)
winexec.Hide(cmd)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("claude %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
Expand Down
4 changes: 2 additions & 2 deletions internal/project/difftext.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func (s *Service) DiffText(path string) (string, error) {
// untrackedFiles lists paths unknown to git, relative to the work tree root.
// Errors yield an empty list — the tracked diff is still worth returning.
func untrackedFiles(path string) []string {
out, err := exec.Command("git", "-C", path, "ls-files", "--others", "--exclude-standard", "-z").Output()
out, err := command("git", "-C", path, "ls-files", "--others", "--exclude-standard", "-z").Output()
if err != nil {
return nil
}
Expand All @@ -61,7 +61,7 @@ func untrackedDiff(dir, rel string) string {
if info, err := os.Stat(filepath.Join(dir, rel)); err != nil || !info.Mode().IsRegular() || info.Size() > maxUntrackedDiffSize {
return ""
}
cmd := exec.Command("git", "-C", dir, "diff", "--no-index", "--", os.DevNull, rel)
cmd := command("git", "-C", dir, "diff", "--no-index", "--", os.DevNull, rel)
out, err := cmd.Output()
var exitErr *exec.ExitError
if err != nil && (!errors.As(err, &exitErr) || exitErr.ExitCode() != 1) {
Expand Down
21 changes: 17 additions & 4 deletions internal/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,20 @@ import (
"strconv"
"strings"
"time"

"github.com/omartelo/lich/internal/winexec"
)

// command builds an exec.Cmd for a child console tool (git, gh) with its
// console window suppressed on Windows — lich's GUI-subsystem binary would
// otherwise pop one per spawn (winexec.Hide). Used across this package instead
// of exec.Command so no polling call site can forget it.
func command(name string, args ...string) *exec.Cmd {
cmd := exec.Command(name, args...)
winexec.Hide(cmd)
return cmd
}

// Project identifies an opened project directory.
type Project struct {
ID string `json:"id"`
Expand Down Expand Up @@ -66,7 +78,7 @@ func newProject(path string) *Project {
// the current state on call, so a checkout made after opening is not reflected
// until the branch is resolved again.
func (s *Service) Branch(path string) string {
out, err := exec.Command("git", "-C", path, "symbolic-ref", "--short", "HEAD").Output()
out, err := command("git", "-C", path, "symbolic-ref", "--short", "HEAD").Output()
if err != nil {
return ""
}
Expand Down Expand Up @@ -95,6 +107,7 @@ func (s *Service) PullRequest(path string) *PullRequest {
defer cancel()
cmd := exec.CommandContext(ctx, "gh", "pr", "view", "--json", "number,url,state")
cmd.Dir = path
winexec.Hide(cmd)
out, err := cmd.Output()
if err != nil {
return nil
Expand Down Expand Up @@ -139,7 +152,7 @@ type DiffStats struct {
// Branch's contract.
func (s *Service) Diff(path string) DiffStats {
var stats DiffStats
if out, err := exec.Command("git", "-C", path, "status", "--porcelain").Output(); err == nil {
if out, err := command("git", "-C", path, "status", "--porcelain").Output(); err == nil {
stats.Files = countLines(out)
}
// A repository without commits has no HEAD; diff against git's empty tree,
Expand All @@ -148,7 +161,7 @@ func (s *Service) Diff(path string) DiffStats {
if _, err := runGit(path, "rev-parse", "--verify", "HEAD"); err != nil {
base = emptyTreeHash
}
if out, err := exec.Command("git", "-C", path, "diff", "--numstat", base).Output(); err == nil {
if out, err := command("git", "-C", path, "diff", "--numstat", base).Output(); err == nil {
for line := range strings.Lines(string(out)) {
cols := strings.Fields(line)
if len(cols) < 3 {
Expand All @@ -163,7 +176,7 @@ func (s *Service) Diff(path string) DiffStats {
}
// Untracked files are invisible to `git diff`; count their lines as
// additions, the way Warp and forge diff views present a fresh file.
if out, err := exec.Command("git", "-C", path, "ls-files", "--others", "--exclude-standard", "-z").Output(); err == nil {
if out, err := command("git", "-C", path, "ls-files", "--others", "--exclude-standard", "-z").Output(); err == nil {
for _, rel := range strings.Split(string(out), "\x00") {
if rel != "" {
stats.Added += countFileLines(filepath.Join(path, rel))
Expand Down
5 changes: 2 additions & 3 deletions internal/project/worktree.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
Expand All @@ -28,7 +27,7 @@ type Branches struct {
// deliberately swallow errors for polling, failures here carry git's stderr in
// the message so the frontend can show it verbatim.
func runGit(dir string, args ...string) (string, error) {
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
cmd := command("git", append([]string{"-C", dir}, args...)...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
Expand Down Expand Up @@ -198,7 +197,7 @@ func (s *Service) WorktreeDirty(wtPath string) (bool, error) {

// branchExists reports whether refs/heads/<name> exists in the repository.
func branchExists(projectPath, name string) bool {
err := exec.Command("git", "-C", projectPath, "show-ref", "--verify", "--quiet", "refs/heads/"+name).Run()
err := command("git", "-C", projectPath, "show-ref", "--verify", "--quiet", "refs/heads/"+name).Run()
return err == nil
}

Expand Down
8 changes: 8 additions & 0 deletions internal/winexec/winexec_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//go:build !windows

package winexec

import "os/exec"

// Hide is a no-op off Windows, where a console child has no window to hide.
func Hide(cmd *exec.Cmd) {}
18 changes: 18 additions & 0 deletions internal/winexec/winexec_other_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//go:build !windows

package winexec

import (
"os/exec"
"testing"
)

// Off Windows there is no console window to hide, so Hide must leave the
// command untouched — anything else would be a portability surprise.
func TestHideIsNoOp(t *testing.T) {
cmd := exec.Command("git", "--version")
Hide(cmd)
if cmd.SysProcAttr != nil {
t.Fatalf("Hide set SysProcAttr off Windows: %#v", cmd.SysProcAttr)
}
}
25 changes: 25 additions & 0 deletions internal/winexec/winexec_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//go:build windows

// Package winexec suppresses the console window Windows would otherwise
// allocate for every console tool lich shells out to (git, gh, claude). lich's
// Windows binary is built for the GUI subsystem (-H=windowsgui) and so has no
// console of its own; without CREATE_NO_WINDOW, each such child pops a fresh
// console window on the user's desktop — and the git-status poll spawning one
// every few seconds per session buries the screen. Off Windows this is a no-op.
package winexec

import (
"os/exec"
"syscall"

"golang.org/x/sys/windows"
)

// Hide sets the creation flag that keeps cmd from allocating a console window.
func Hide(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.HideWindow = true
cmd.SysProcAttr.CreationFlags |= windows.CREATE_NO_WINDOW
}
26 changes: 26 additions & 0 deletions internal/winexec/winexec_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//go:build windows

package winexec

import (
"os/exec"
"testing"

"golang.org/x/sys/windows"
)

// The whole point on Windows: Hide must set CREATE_NO_WINDOW so lich's
// windowless GUI binary does not spawn a console window per child.
func TestHideSetsCreateNoWindow(t *testing.T) {
cmd := exec.Command("git", "--version")
Hide(cmd)
if cmd.SysProcAttr == nil {
t.Fatal("Hide left SysProcAttr nil on Windows")
}
if cmd.SysProcAttr.CreationFlags&windows.CREATE_NO_WINDOW == 0 {
t.Fatalf("CREATE_NO_WINDOW not set: flags=%#x", cmd.SysProcAttr.CreationFlags)
}
if !cmd.SysProcAttr.HideWindow {
t.Fatal("HideWindow not set")
}
}
Loading