Skip to content

Commit 823b0f2

Browse files
authored
feat: add CI/CD status integration to wt status (#84)
* feat: add CI/CD status to wt status --ci Show pipeline status (pass/fail/pending) per branch in the status dashboard. Uses gh CLI for GitHub repos and glab CLI for GitLab. Displays colored indicators: ✓ (pass), ✗ (fail), ● (pending). Opt-in via --ci flag to avoid slowing down the default status command with network calls. * fix: prefer check runs API over commit status for GitHub Actions The commit status API returns "pending" when there are no statuses, which is the common case for repos using GitHub Actions (which uses check runs instead). Check the check runs API first and only fall back to commit status for older CI integrations.
1 parent cf2a562 commit 823b0f2

4 files changed

Lines changed: 327 additions & 3 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Inspired by [haacked/dotfiles/tree-me](https://github.com/haacked/dotfiles/blob/
2222
- **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))
2323
- **Stale worktree detection** — find worktrees with deleted remote branches or inactive commits (`wt cleanup --stale`)
2424
- **Color-coded status output** — green (clean), red (dirty), yellow (ahead/behind), bold cyan (current); respects `NO_COLOR=1` and auto-strips colors when piped
25+
- **CI/CD status integration**`wt status --ci` shows pipeline status (✓/✗/●) per branch via `gh` or `glab` CLI
2526
- **Per-repo `.wt.toml` config** — override global settings (strategy, hooks, etc.) on a per-repository basis
2627
- Shell integration with auto-cd functionality
2728
- Tab completion for Bash and Zsh
@@ -106,9 +107,10 @@ wt config path # print the config file path
106107

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

111-
Shows dirty/clean state, ahead/behind counts, and highlights the current worktree. Colors are automatically stripped when piping; set `NO_COLOR=1` to disable.
113+
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.
112114

113115
### Interactive Selection
114116

main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ func init() {
9898
rootCmd.AddCommand(examplesCmd)
9999
rootCmd.AddCommand(defaultCmd)
100100
rootCmd.AddCommand(statusCmd)
101+
statusCmd.Flags().BoolVar(&statusCI, "ci", false, "Show CI/CD pipeline status for each branch (requires gh or glab CLI)")
101102
removeCmd.Flags().BoolVarP(&removeForce, "force", "f", false, "Force removal even if worktree has modifications")
102103
cleanupCmd.Flags().BoolVar(&cleanupDryRun, "dry-run", false, "Preview what would be removed without making changes")
103104
cleanupCmd.Flags().BoolVarP(&cleanupForce, "force", "f", false, "Remove all merged worktrees without confirmation")

status.go

Lines changed: 193 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"os"
67
"os/exec"
@@ -19,8 +20,11 @@ type worktreeStatus struct {
1920
Behind int `json:"behind"`
2021
Current bool `json:"current"`
2122
HasUpstream bool `json:"has_upstream"`
23+
CI string `json:"ci,omitempty"`
2224
}
2325

26+
var statusCI bool
27+
2428
var statusCmd = &cobra.Command{
2529
Use: "status",
2630
Short: "Show status dashboard of all worktrees",
@@ -29,6 +33,9 @@ var statusCmd = &cobra.Command{
2933
For each worktree, shows: path, branch, dirty/clean state, and
3034
ahead/behind counts relative to upstream.
3135
36+
Use --ci to include CI/CD pipeline status for each branch
37+
(requires gh CLI for GitHub or glab CLI for GitLab).
38+
3239
Use --format json for machine-readable output.`,
3340
RunE: func(cmd *cobra.Command, args []string) error {
3441
entries, err := getWorktreeListPorcelain()
@@ -70,6 +77,14 @@ Use --format json for machine-readable output.`,
7077
statuses = append(statuses, st)
7178
}
7279

80+
// Fetch CI status if requested
81+
if statusCI {
82+
ciStatuses := getCIStatuses(statuses)
83+
for i, ci := range ciStatuses {
84+
statuses[i].CI = ci
85+
}
86+
}
87+
7388
if isJSONOutput() {
7489
return emitJSONSuccess(cmd, map[string]any{"worktrees": statuses})
7590
}
@@ -136,8 +151,13 @@ func formatStatusLineColor(st worktreeStatus, color bool) string {
136151
tracking = "no upstream"
137152
}
138153

154+
ci := ""
155+
if st.CI != "" {
156+
ci = " " + st.CI
157+
}
158+
139159
if !color {
140-
return fmt.Sprintf("%s %-14s %-30s %-7s %s", marker, st.Branch, st.Path, state, tracking)
160+
return fmt.Sprintf("%s %-14s %-30s %-7s %s%s", marker, st.Branch, st.Path, state, tracking, ci)
141161
}
142162

143163
// Apply colors
@@ -167,7 +187,25 @@ func formatStatusLineColor(st worktreeStatus, color bool) string {
167187
tracking = aheadStr + " " + behindStr
168188
}
169189

170-
return fmt.Sprintf("%s %-14s %-30s %-7s %s", marker, branch, st.Path, state, tracking)
190+
if st.CI != "" {
191+
ci = " " + formatCIColor(st.CI)
192+
}
193+
194+
return fmt.Sprintf("%s %-14s %-30s %-7s %s%s", marker, branch, st.Path, state, tracking, ci)
195+
}
196+
197+
// formatCIColor applies color to a CI status string.
198+
func formatCIColor(ci string) string {
199+
switch ci {
200+
case "pass":
201+
return colorize("✓ CI", ansiGreen)
202+
case "fail":
203+
return colorize("✗ CI", ansiRed)
204+
case "pending":
205+
return colorize("● CI", ansiYellow)
206+
default:
207+
return colorize(ci, ansiDim)
208+
}
171209
}
172210

173211
// gitStatusPorcelain runs git status --porcelain in the given directory.
@@ -189,3 +227,156 @@ func gitRevListAheadBehind(dir string) (string, error) {
189227
}
190228
return string(output), nil
191229
}
230+
231+
// getCIStatuses fetches CI check status for each worktree branch.
232+
// It detects whether the repo uses GitHub or GitLab and calls the
233+
// appropriate CLI tool. Returns a slice parallel to statuses.
234+
func getCIStatuses(statuses []worktreeStatus) []string {
235+
results := make([]string, len(statuses))
236+
237+
remoteType := detectCIRemoteType()
238+
if remoteType == RemoteUnknown {
239+
return results
240+
}
241+
242+
for i, st := range statuses {
243+
if st.Branch == "" {
244+
continue
245+
}
246+
results[i] = fetchCIStatus(st.Branch, remoteType)
247+
}
248+
return results
249+
}
250+
251+
// detectCIRemoteType checks if gh or glab CLI is available and
252+
// whether the remote points to GitHub or GitLab.
253+
func detectCIRemoteType() RemoteType {
254+
cmd := exec.Command("git", "remote", "get-url", "origin")
255+
output, err := cmd.Output()
256+
if err != nil {
257+
return RemoteUnknown
258+
}
259+
url := strings.TrimSpace(string(output))
260+
261+
if strings.Contains(url, "github.com") {
262+
if _, err := exec.LookPath("gh"); err == nil {
263+
return RemoteGitHub
264+
}
265+
}
266+
if strings.Contains(url, "gitlab") {
267+
if _, err := exec.LookPath("glab"); err == nil {
268+
return RemoteGitLab
269+
}
270+
}
271+
return RemoteUnknown
272+
}
273+
274+
// fetchCIStatus returns the CI status for a single branch.
275+
// Returns "pass", "fail", "pending", or "" if unavailable.
276+
func fetchCIStatus(branch string, remoteType RemoteType) string {
277+
switch remoteType {
278+
case RemoteGitHub:
279+
return fetchGitHubCIStatus(branch)
280+
case RemoteGitLab:
281+
return fetchGitLabCIStatus(branch)
282+
default:
283+
return ""
284+
}
285+
}
286+
287+
// fetchGitHubCIStatus uses gh to get CI status for a branch.
288+
// Checks both the commit status API and the check runs API (GitHub Actions),
289+
// preferring check runs when the commit status API has no results.
290+
func fetchGitHubCIStatus(branch string) string {
291+
// Try check runs first (GitHub Actions uses this)
292+
checkResult := fetchGitHubCheckRuns(branch)
293+
if checkResult != "" {
294+
return checkResult
295+
}
296+
297+
// Fall back to commit status API (older CI integrations)
298+
cmd := exec.Command("gh", "api",
299+
fmt.Sprintf("repos/{owner}/{repo}/commits/%s/status", branch),
300+
"--jq", ".state")
301+
output, err := cmd.Output()
302+
if err != nil {
303+
return ""
304+
}
305+
return normalizeGitHubState(strings.TrimSpace(string(output)))
306+
}
307+
308+
// fetchGitHubCheckRuns uses gh to get check run conclusions for a branch.
309+
func fetchGitHubCheckRuns(branch string) string {
310+
cmd := exec.Command("gh", "api",
311+
fmt.Sprintf("repos/{owner}/{repo}/commits/%s/check-runs", branch),
312+
"--jq", ".check_runs | map(.conclusion) | unique | join(\",\")")
313+
output, err := cmd.Output()
314+
if err != nil {
315+
return ""
316+
}
317+
return normalizeGitHubCheckRuns(strings.TrimSpace(string(output)))
318+
}
319+
320+
// normalizeGitHubState maps the GitHub combined status API state to our status.
321+
func normalizeGitHubState(state string) string {
322+
switch state {
323+
case "success":
324+
return "pass"
325+
case "failure", "error":
326+
return "fail"
327+
case "pending":
328+
return "pending"
329+
default:
330+
return ""
331+
}
332+
}
333+
334+
// normalizeGitHubCheckRuns maps GitHub check run conclusions to our status.
335+
// Returns "" when there are no check runs at all.
336+
func normalizeGitHubCheckRuns(conclusions string) string {
337+
if conclusions == "" {
338+
return ""
339+
}
340+
for _, c := range strings.Split(conclusions, ",") {
341+
switch c {
342+
case "failure", "timed_out", "cancelled", "action_required":
343+
return "fail"
344+
case "null", "":
345+
return "pending"
346+
}
347+
}
348+
return "pass"
349+
}
350+
351+
// fetchGitLabCIStatus uses glab to get the pipeline status for a branch.
352+
func fetchGitLabCIStatus(branch string) string {
353+
cmd := exec.Command("glab", "ci", "status", "--branch", branch, "--output", "json")
354+
output, err := cmd.Output()
355+
if err != nil {
356+
return ""
357+
}
358+
return normalizeGitLabState(strings.TrimSpace(string(output)))
359+
}
360+
361+
// normalizeGitLabState maps glab ci status JSON output to our status.
362+
func normalizeGitLabState(jsonOutput string) string {
363+
// glab ci status --output json returns {"status":"success",...}
364+
var result struct {
365+
Status string `json:"status"`
366+
}
367+
if err := json.Unmarshal([]byte(jsonOutput), &result); err != nil {
368+
return ""
369+
}
370+
switch result.Status {
371+
case "success":
372+
return "pass"
373+
case "failed":
374+
return "fail"
375+
case "running", "pending", "created", "waiting_for_resource", "preparing":
376+
return "pending"
377+
case "canceled", "skipped":
378+
return ""
379+
default:
380+
return ""
381+
}
382+
}

0 commit comments

Comments
 (0)