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: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Inspired by [haacked/dotfiles/tree-me](https://github.com/haacked/dotfiles/blob/
- **Pre/post command hooks** — run custom scripts on create/checkout/remove (e.g. [launch AI assistants](docs/examples.md#ai-assistants-and-editors), [share build caches](docs/examples.md#shared-build-cache-across-worktrees), [assign dev server ports](docs/examples.md#deterministic-dev-server-port-per-worktree), [copy `.env`](docs/examples.md#copy-env-to-new-worktrees))
- **Stale worktree detection** — find worktrees with deleted remote branches or inactive commits (`wt cleanup --stale`)
- **Color-coded status output** — green (clean), red (dirty), yellow (ahead/behind), bold cyan (current); respects `NO_COLOR=1` and auto-strips colors when piped
- **CI/CD status integration** — `wt status --ci` shows pipeline status (✓/✗/●) per branch via `gh` or `glab` CLI
- **Per-repo `.wt.toml` config** — override global settings (strategy, hooks, etc.) on a per-repository basis
- Shell integration with auto-cd functionality
- Tab completion for Bash and Zsh
Expand Down Expand Up @@ -106,9 +107,10 @@ wt config path # print the config file path

```bash
wt status # color-coded overview of all worktrees
wt status --ci # include CI/CD pipeline status (requires gh or glab)
```

Shows dirty/clean state, ahead/behind counts, and highlights the current worktree. Colors are automatically stripped when piping; set `NO_COLOR=1` to disable.
Shows dirty/clean state, ahead/behind counts, and highlights the current worktree. With `--ci`, each branch shows ✓ (pass), ✗ (fail), or ● (pending) for its latest CI pipeline. Colors are automatically stripped when piping; set `NO_COLOR=1` to disable.

### Interactive Selection

Expand Down
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ func init() {
rootCmd.AddCommand(examplesCmd)
rootCmd.AddCommand(defaultCmd)
rootCmd.AddCommand(statusCmd)
statusCmd.Flags().BoolVar(&statusCI, "ci", false, "Show CI/CD pipeline status for each branch (requires gh or glab CLI)")
removeCmd.Flags().BoolVarP(&removeForce, "force", "f", false, "Force removal even if worktree has modifications")
cleanupCmd.Flags().BoolVar(&cleanupDryRun, "dry-run", false, "Preview what would be removed without making changes")
cleanupCmd.Flags().BoolVarP(&cleanupForce, "force", "f", false, "Remove all merged worktrees without confirmation")
Expand Down
195 changes: 193 additions & 2 deletions status.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"encoding/json"
"fmt"
"os"
"os/exec"
Expand All @@ -19,8 +20,11 @@ type worktreeStatus struct {
Behind int `json:"behind"`
Current bool `json:"current"`
HasUpstream bool `json:"has_upstream"`
CI string `json:"ci,omitempty"`
}

var statusCI bool

var statusCmd = &cobra.Command{
Use: "status",
Short: "Show status dashboard of all worktrees",
Expand All @@ -29,6 +33,9 @@ var statusCmd = &cobra.Command{
For each worktree, shows: path, branch, dirty/clean state, and
ahead/behind counts relative to upstream.

Use --ci to include CI/CD pipeline status for each branch
(requires gh CLI for GitHub or glab CLI for GitLab).

Use --format json for machine-readable output.`,
RunE: func(cmd *cobra.Command, args []string) error {
entries, err := getWorktreeListPorcelain()
Expand Down Expand Up @@ -70,6 +77,14 @@ Use --format json for machine-readable output.`,
statuses = append(statuses, st)
}

// Fetch CI status if requested
if statusCI {
ciStatuses := getCIStatuses(statuses)
for i, ci := range ciStatuses {
statuses[i].CI = ci
}
}

if isJSONOutput() {
return emitJSONSuccess(cmd, map[string]any{"worktrees": statuses})
}
Expand Down Expand Up @@ -136,8 +151,13 @@ func formatStatusLineColor(st worktreeStatus, color bool) string {
tracking = "no upstream"
}

ci := ""
if st.CI != "" {
ci = " " + st.CI
}

if !color {
return fmt.Sprintf("%s %-14s %-30s %-7s %s", marker, st.Branch, st.Path, state, tracking)
return fmt.Sprintf("%s %-14s %-30s %-7s %s%s", marker, st.Branch, st.Path, state, tracking, ci)
}

// Apply colors
Expand Down Expand Up @@ -167,7 +187,25 @@ func formatStatusLineColor(st worktreeStatus, color bool) string {
tracking = aheadStr + " " + behindStr
}

return fmt.Sprintf("%s %-14s %-30s %-7s %s", marker, branch, st.Path, state, tracking)
if st.CI != "" {
ci = " " + formatCIColor(st.CI)
}

return fmt.Sprintf("%s %-14s %-30s %-7s %s%s", marker, branch, st.Path, state, tracking, ci)
}

// formatCIColor applies color to a CI status string.
func formatCIColor(ci string) string {
switch ci {
case "pass":
return colorize("✓ CI", ansiGreen)
case "fail":
return colorize("✗ CI", ansiRed)
case "pending":
return colorize("● CI", ansiYellow)
default:
return colorize(ci, ansiDim)
}
}

// gitStatusPorcelain runs git status --porcelain in the given directory.
Expand All @@ -189,3 +227,156 @@ func gitRevListAheadBehind(dir string) (string, error) {
}
return string(output), nil
}

// getCIStatuses fetches CI check status for each worktree branch.
// It detects whether the repo uses GitHub or GitLab and calls the
// appropriate CLI tool. Returns a slice parallel to statuses.
func getCIStatuses(statuses []worktreeStatus) []string {
results := make([]string, len(statuses))

remoteType := detectCIRemoteType()
if remoteType == RemoteUnknown {
return results
}

for i, st := range statuses {
if st.Branch == "" {
continue
}
results[i] = fetchCIStatus(st.Branch, remoteType)
}
return results
}

// detectCIRemoteType checks if gh or glab CLI is available and
// whether the remote points to GitHub or GitLab.
func detectCIRemoteType() RemoteType {
cmd := exec.Command("git", "remote", "get-url", "origin")
output, err := cmd.Output()
if err != nil {
return RemoteUnknown
}
url := strings.TrimSpace(string(output))

if strings.Contains(url, "github.com") {
if _, err := exec.LookPath("gh"); err == nil {
return RemoteGitHub
}
}
if strings.Contains(url, "gitlab") {
if _, err := exec.LookPath("glab"); err == nil {
return RemoteGitLab
}
}
return RemoteUnknown
}

// fetchCIStatus returns the CI status for a single branch.
// Returns "pass", "fail", "pending", or "" if unavailable.
func fetchCIStatus(branch string, remoteType RemoteType) string {
switch remoteType {
case RemoteGitHub:
return fetchGitHubCIStatus(branch)
case RemoteGitLab:
return fetchGitLabCIStatus(branch)
default:
return ""
}
}

// fetchGitHubCIStatus uses gh to get CI status for a branch.
// Checks both the commit status API and the check runs API (GitHub Actions),
// preferring check runs when the commit status API has no results.
func fetchGitHubCIStatus(branch string) string {
// Try check runs first (GitHub Actions uses this)
checkResult := fetchGitHubCheckRuns(branch)
if checkResult != "" {
return checkResult
}

// Fall back to commit status API (older CI integrations)
cmd := exec.Command("gh", "api",
fmt.Sprintf("repos/{owner}/{repo}/commits/%s/status", branch),
"--jq", ".state")
output, err := cmd.Output()
if err != nil {
return ""
}
return normalizeGitHubState(strings.TrimSpace(string(output)))
}

// fetchGitHubCheckRuns uses gh to get check run conclusions for a branch.
func fetchGitHubCheckRuns(branch string) string {
cmd := exec.Command("gh", "api",
fmt.Sprintf("repos/{owner}/{repo}/commits/%s/check-runs", branch),
"--jq", ".check_runs | map(.conclusion) | unique | join(\",\")")
output, err := cmd.Output()
if err != nil {
return ""
}
return normalizeGitHubCheckRuns(strings.TrimSpace(string(output)))
}

// normalizeGitHubState maps the GitHub combined status API state to our status.
func normalizeGitHubState(state string) string {
switch state {
case "success":
return "pass"
case "failure", "error":
return "fail"
case "pending":
return "pending"
default:
return ""
}
}

// normalizeGitHubCheckRuns maps GitHub check run conclusions to our status.
// Returns "" when there are no check runs at all.
func normalizeGitHubCheckRuns(conclusions string) string {
if conclusions == "" {
return ""
}
for _, c := range strings.Split(conclusions, ",") {
switch c {
case "failure", "timed_out", "cancelled", "action_required":
return "fail"
case "null", "":
return "pending"
}
}
return "pass"
}

// fetchGitLabCIStatus uses glab to get the pipeline status for a branch.
func fetchGitLabCIStatus(branch string) string {
cmd := exec.Command("glab", "ci", "status", "--branch", branch, "--output", "json")
output, err := cmd.Output()
if err != nil {
return ""
}
return normalizeGitLabState(strings.TrimSpace(string(output)))
}

// normalizeGitLabState maps glab ci status JSON output to our status.
func normalizeGitLabState(jsonOutput string) string {
// glab ci status --output json returns {"status":"success",...}
var result struct {
Status string `json:"status"`
}
if err := json.Unmarshal([]byte(jsonOutput), &result); err != nil {
return ""
}
switch result.Status {
case "success":
return "pass"
case "failed":
return "fail"
case "running", "pending", "created", "waiting_for_resource", "preparing":
return "pending"
case "canceled", "skipped":
return ""
default:
return ""
}
}
Loading