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: 2 additions & 2 deletions cmd/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ func runList(cmd *cobra.Command, args []string) error {
continue
}
if listDirty {
dirty, _ := git.HasUncommittedChanges(ws.Path)
if !dirty {
dc, _ := git.DirtyStatus(ws.Path)
if !dc.Dirty() {
continue
}
}
Expand Down
41 changes: 29 additions & 12 deletions cmd/mcp_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/mark3labs/mcp-go/server"
"github.com/protocollar/fr8/internal/config"
"github.com/protocollar/fr8/internal/env"
"github.com/protocollar/fr8/internal/gh"
"github.com/protocollar/fr8/internal/git"
"github.com/protocollar/fr8/internal/registry"
"github.com/protocollar/fr8/internal/state"
Expand Down Expand Up @@ -268,8 +269,8 @@ func handleWorkspaceList(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal
continue
}
if filterDirty {
dirty, _ := git.HasUncommittedChanges(ws.Path)
if !dirty {
dc, _ := git.DirtyStatus(ws.Path)
if !dc.Dirty() {
continue
}
}
Expand Down Expand Up @@ -314,7 +315,18 @@ func handleWorkspaceStatus(ctx context.Context, req mcp.CallToolRequest) (*mcp.C
branch = ws.Branch
}

dirty, _ := git.HasUncommittedChanges(ws.Path)
dc, _ := git.DirtyStatus(ws.Path)
lastCommit, _ := git.LastCommit(ws.Path)
var lastCommitPtr *git.CommitInfo
if lastCommit.Subject != "" {
lastCommitPtr = &lastCommit
}

var pr *gh.PRInfo
if gh.Available() == nil {
pr, _ = gh.PRStatus(ws.Path, branch)
}

running := false
if tmux.Available() == nil {
sessionName := tmux.SessionName(tmux.RepoName(rootPath), ws.Name)
Expand All @@ -331,15 +343,20 @@ func handleWorkspaceStatus(ctx context.Context, req mcp.CallToolRequest) (*mcp.C
}

return mcpResult(workspaceStatusJSON{
Name: ws.Name,
Path: ws.Path,
Branch: branch,
Port: ws.Port,
PortEnd: ws.Port + 9,
Dirty: dirty,
Running: running,
CreatedAt: ws.CreatedAt,
Env: envMap,
Name: ws.Name,
Path: ws.Path,
Branch: branch,
Port: ws.Port,
PortEnd: ws.Port + 9,
Dirty: dc.Dirty(),
Staged: dc.Staged,
Modified: dc.Modified,
Untracked: dc.Untracked,
Running: running,
CreatedAt: ws.CreatedAt,
Env: envMap,
LastCommit: lastCommitPtr,
PR: pr,
})
}

Expand Down
55 changes: 43 additions & 12 deletions cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/spf13/cobra"
"github.com/protocollar/fr8/internal/env"
"github.com/protocollar/fr8/internal/gh"
"github.com/protocollar/fr8/internal/git"
"github.com/protocollar/fr8/internal/jsonout"
"github.com/protocollar/fr8/internal/tmux"
Expand All @@ -33,9 +34,14 @@ type workspaceStatusJSON struct {
Port int `json:"port"`
PortEnd int `json:"port_end"`
Dirty bool `json:"dirty"`
Staged int `json:"staged"`
Modified int `json:"modified"`
Untracked int `json:"untracked"`
Running bool `json:"running"`
CreatedAt time.Time `json:"created_at"`
Env map[string]string `json:"env"`
LastCommit *git.CommitInfo `json:"last_commit,omitempty"`
PR *gh.PRInfo `json:"pr,omitempty"`
}

func (w workspaceStatusJSON) Concise() any {
Expand Down Expand Up @@ -66,7 +72,17 @@ func runStatus(cmd *cobra.Command, args []string) error {
branch = ws.Branch
}

dirty, _ := git.HasUncommittedChanges(ws.Path)
dc, _ := git.DirtyStatus(ws.Path)
lastCommit, _ := git.LastCommit(ws.Path)
var lastCommitPtr *git.CommitInfo
if lastCommit.Subject != "" {
lastCommitPtr = &lastCommit
}

var pr *gh.PRInfo
if gh.Available() == nil {
pr, _ = gh.PRStatus(ws.Path, branch)
}

running := false
if tmux.Available() == nil {
Expand All @@ -84,28 +100,43 @@ func runStatus(cmd *cobra.Command, args []string) error {
}
}
return jsonout.Write(workspaceStatusJSON{
Name: ws.Name,
Path: ws.Path,
Branch: branch,
Port: ws.Port,
PortEnd: ws.Port + 9,
Dirty: dirty,
Running: running,
CreatedAt: ws.CreatedAt,
Env: envMap,
Name: ws.Name,
Path: ws.Path,
Branch: branch,
Port: ws.Port,
PortEnd: ws.Port + 9,
Dirty: dc.Dirty(),
Staged: dc.Staged,
Modified: dc.Modified,
Untracked: dc.Untracked,
Running: running,
CreatedAt: ws.CreatedAt,
Env: envMap,
LastCommit: lastCommitPtr,
PR: pr,
})
}

fmt.Printf("Workspace: %s\n", ws.Name)
fmt.Printf(" Path: %s\n", ws.Path)
fmt.Printf(" Branch: %s\n", branch)
if dirty {
fmt.Printf(" Status: dirty (uncommitted changes)\n")
if dc.Dirty() {
fmt.Printf(" Status: dirty (%d staged, %d modified, %d untracked)\n", dc.Staged, dc.Modified, dc.Untracked)
} else {
fmt.Printf(" Status: clean\n")
}
fmt.Printf(" Port: %d (range %d-%d)\n", ws.Port, ws.Port, ws.Port+9)
fmt.Printf(" Created: %s\n", ws.CreatedAt.Format("2006-01-02 15:04:05"))
if lastCommitPtr != nil {
fmt.Printf(" Last Commit: %s (%s)\n", lastCommit.Subject, lastCommit.Time.Format("2006-01-02 15:04"))
}
if pr != nil {
draft := ""
if pr.IsDraft {
draft = " (draft)"
}
fmt.Printf(" PR: #%d %s%s\n", pr.Number, pr.State, draft)
}
fmt.Println()
fmt.Printf("Environment:\n")
fmt.Printf(" FR8_WORKSPACE_NAME %s\n", ws.Name)
Expand Down
55 changes: 55 additions & 0 deletions internal/gh/gh.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package gh

import (
"encoding/json"
"os/exec"
"strings"
)

// PRInfo holds GitHub pull request status.
type PRInfo struct {
Number int `json:"number"`
State string `json:"state"`
IsDraft bool `json:"is_draft"`
ReviewDecision string `json:"review_decision"`
URL string `json:"url"`
}

// Available returns nil if the gh CLI is installed.
func Available() error {
_, err := exec.LookPath("gh")
return err
}

// PRStatus returns PR info for the given branch, or nil if no PR exists.
// Returns nil, nil for all non-critical failures (gh missing, not a GitHub repo,
// no PR for the branch).
func PRStatus(dir, branch string) (*PRInfo, error) {
if Available() != nil {
return nil, nil
}

cmd := exec.Command("gh", "pr", "view", branch,
"--json", "number,state,isDraft,reviewDecision,url",
"-q", ".")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
// gh returns non-zero for "no PR found", not a GitHub repo, etc.
return nil, nil
}

trimmed := strings.TrimSpace(string(out))
if trimmed == "" {
return nil, nil
}

var pr PRInfo
if err := json.Unmarshal([]byte(trimmed), &pr); err != nil {
return nil, nil
}
if pr.Number == 0 {
return nil, nil
}
return &pr, nil
}
15 changes: 15 additions & 0 deletions internal/gh/gh_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package gh

import "testing"

func TestPRStatusGracefulDegradation(t *testing.T) {
// A temp dir with no GitHub remote should return nil, nil
dir := t.TempDir()
pr, err := PRStatus(dir, "main")
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
if pr != nil {
t.Errorf("expected nil PRInfo, got %+v", pr)
}
}
68 changes: 68 additions & 0 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
)

// Worktree represents a git worktree entry.
Expand Down Expand Up @@ -118,6 +119,73 @@ func HasUncommittedChanges(dir string) (bool, error) {
return strings.TrimSpace(out) != "", nil
}

// DirtyCount holds counts of staged, modified, and untracked files.
type DirtyCount struct {
Staged int `json:"staged"`
Modified int `json:"modified"`
Untracked int `json:"untracked"`
}

// Dirty returns true if any files are staged, modified, or untracked.
func (d DirtyCount) Dirty() bool {
return d.Staged > 0 || d.Modified > 0 || d.Untracked > 0
}

// DirtyStatus parses git status --porcelain output and returns file counts.
func DirtyStatus(dir string) (DirtyCount, error) {
out, err := run(dir, "status", "--porcelain")
if err != nil {
return DirtyCount{}, fmt.Errorf("git status: %w", err)
}

var dc DirtyCount
for _, line := range strings.Split(out, "\n") {
if len(line) < 2 {
continue
}
if line[:2] == "??" {
dc.Untracked++
continue
}
if line[0] != ' ' && line[0] != '?' {
dc.Staged++
}
if line[1] != ' ' && line[1] != '?' {
dc.Modified++
}
}
return dc, nil
}

// CommitInfo holds summary information about a commit.
type CommitInfo struct {
Subject string `json:"subject"`
Time time.Time `json:"time"`
}

// LastCommit returns the subject and timestamp of the most recent commit.
func LastCommit(dir string) (CommitInfo, error) {
out, err := run(dir, "log", "-1", "--format=%s|||%ct")
if err != nil {
return CommitInfo{}, fmt.Errorf("git log: %w", err)
}

parts := strings.SplitN(strings.TrimSpace(out), "|||", 2)
if len(parts) != 2 {
return CommitInfo{}, fmt.Errorf("unexpected git log output: %q", out)
}

ts, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return CommitInfo{}, fmt.Errorf("parsing commit timestamp: %w", err)
}

return CommitInfo{
Subject: parts[0],
Time: time.Unix(ts, 0),
}, nil
}

// IsInsideWorkTree returns true if dir is inside a git repository.
func IsInsideWorkTree(dir string) bool {
_, err := run(dir, "rev-parse", "--is-inside-work-tree")
Expand Down
Loading