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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ tar xzf ws-dev_*_linux_amd64.tar.gz && sudo mv ws-dev /usr/local/bin/

| Package | Responsibility |
|---------|----------------|
| `internal/cmd` | Cobra subcommand definitions (init/server/logs/run/tasks/mcp/update/version). `context.go` resolves the repo config + worktree (replaces the old workspace lookup). `update.go` self-updates from the latest GitHub release (checksum-verified, atomic replace; the API call is authenticated via `$GH_TOKEN` / `$GITHUB_TOKEN` / `gh auth token` to avoid the 60/hour unauthenticated rate limit). |
| `internal/cmd` | Cobra subcommand definitions (init/server/status/logs/run/tasks/mcp/update/version). `context.go` resolves the repo config + worktree (replaces the old workspace lookup). `update.go` self-updates from the latest GitHub release (checksum-verified, atomic replace; the API call is authenticated via `$GH_TOKEN` / `$GITHUB_TOKEN` / `gh auth token` to avoid the 60/hour unauthenticated rate limit). |
| `internal/config` | Parses `~/.config/ws-dev/config.yml` (`repos:` map). `Lookup(remote)` / `NormalizeRemote` match repos by git remote regardless of ssh/https form. `DefaultPath()` honors `$WS_DEV_CONFIG` / `$XDG_CONFIG_HOME`. |
| `internal/git` | Thin git wrappers (`Remote`, `CommonDir`, `Worktrees`) plus pure helpers (`ParseWorktrees`, `ResolveWorktree`, `CurrentWorktree`, `MainRoot`). |
| `internal/tasks` | Runs commands prefixed with `exec_wrapper`, inheriting stdio. Operates on a `config.RepoConfig`. |
Expand Down Expand Up @@ -108,6 +108,8 @@ as if the feature always existed.
- `<git-common-dir>/ws-dev/server.pid` — our own PID
- `<git-common-dir>/ws-dev/current-worktree` — most recent worktree (used by `ws-dev logs` when omitted)

`ws-dev status` reads these two files (liveness via signal 0) to report whether a server is running and for which worktree; it needs no config entry, so it works in unregistered repos too.

### Background mode

`ws-dev server -b` / `--background` starts the server detached and returns immediately. The foreground process validates the config + worktree (so misconfiguration surfaces synchronously), then re-execs itself without `-b` in a new session (`Setsid`) with the child's combined output redirected to `<log-dir>/server.log`. The detached child runs the normal foreground flow, so it owns the pid file and process lifecycle exactly as a foreground run would; `ws-dev server stop` (or another `ws-dev server`) stops it via the recorded pid like any other run. Per-process logs still go to `<log-dir>/<name>.log`. Because `setup` runs in the foreground flow, it executes in the detached child (its output lands in `server.log`), keeping the `-b` parent's return immediate.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ recent `ws-dev server` run.
| `ws-dev init` | Create `~/.config/ws-dev/config.yml` (if missing) and print the config key for the current repo. |
| `ws-dev server [<worktree>]` | Start `processes` in parallel inside the worktree. Any prior `ws-dev server` for the repo is stopped first. |
| `ws-dev server stop` | Stop the current server. |
| `ws-dev status` | Show whether the server is running (pid) and for which worktree. |
| `ws-dev logs [<worktree>] [<name>]` | List `*.log` or tail a specific log. |
| `ws-dev run [<worktree>] <task> [args...]` | Run a task defined under `tasks:`; extra args pass through. |
| `ws-dev tasks` | List tasks defined for the current repo. |
Expand All @@ -123,7 +124,8 @@ Flags:
(`<git-common-dir>/ws-dev/`, i.e. inside `.git`, never committed and shared by
all worktrees). Starting a new server stops any prior `ws-dev server` for the
same repo first (SIGTERM, then SIGKILL on timeout). Running multiple worktrees
in parallel is not supported, to avoid port conflicts.
in parallel is not supported, to avoid port conflicts. `ws-dev status` shows
whether a server is currently running and for which worktree.

## `~/.config/ws-dev/config.yml`

Expand Down
1 change: 1 addition & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ settings live in ~/.config/ws-dev/config.yml, keyed by the repo's git remote.`,
root.AddCommand(
newInitCmd(),
newServerCmd(),
newStatusCmd(),
newLogsCmd(),
newRunCmd(),
newTasksCmd(),
Expand Down
31 changes: 22 additions & 9 deletions internal/cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,31 +185,44 @@ func startBackground(worktreeArg string, portBase int, logDirFlag, logAbs string
return nil
}

// stopPrior reads pidPath, sends SIGTERM to the process, waits for it to exit,
// and returns whether a running process was stopped. Missing pid file is OK.
func stopPrior(pidPath string) (bool, error) {
// readServerPid reads pidPath and reports the recorded pid and whether that
// process is currently alive (checked with signal 0). A missing, malformed, or
// stale pid file yields alive=false; stale files are removed on the way.
func readServerPid(pidPath string) (pid int, alive bool, err error) {
data, err := os.ReadFile(pidPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return false, nil
return 0, false, nil
}
return false, err
return 0, false, err
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
pid, err = strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil || pid <= 0 {
_ = os.Remove(pidPath)
return false, nil
return 0, false, nil
}
proc, err := os.FindProcess(pid)
if err != nil {
_ = os.Remove(pidPath)
return false, nil
return 0, false, nil
}
// Signal 0: check liveness.
if err := proc.Signal(syscall.Signal(0)); err != nil {
_ = os.Remove(pidPath)
return false, nil
return 0, false, nil
}
return pid, true, nil
}

// stopPrior reads pidPath, sends SIGTERM to the process, waits for it to exit,
// and returns whether a running process was stopped. Missing pid file is OK.
func stopPrior(pidPath string) (bool, error) {
pid, alive, err := readServerPid(pidPath)
if err != nil || !alive {
return false, err
}
// FindProcess never fails on unix; readServerPid just confirmed liveness.
proc, _ := os.FindProcess(pid)
if err := proc.Signal(syscall.SIGTERM); err != nil {
return false, err
}
Expand Down
42 changes: 42 additions & 0 deletions internal/cmd/status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package cmd

import (
"fmt"
"path/filepath"

"github.com/spf13/cobra"
)

func newStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show whether the ws-dev server is running and for which worktree",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
sdir, err := stateDir()
if err != nil {
return err
}
pid, alive, err := readServerPid(filepath.Join(sdir, pidFileName))
if err != nil {
return err
}
// The recorded worktree is informational either way: while running
// it names the active server's worktree; otherwise the most recent
// run (the same default `ws-dev logs` uses).
worktree, _ := readCurrentWorktree()
if !alive {
fmt.Println("No ws-dev server running")
if worktree != "" {
fmt.Printf("Last worktree: %s\n", worktree)
}
return nil
}
fmt.Printf("ws-dev server running (pid %d)\n", pid)
if worktree != "" {
fmt.Printf("Worktree: %s\n", worktree)
}
return nil
},
}
}
36 changes: 36 additions & 0 deletions internal/cmd/status_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package cmd

import (
"os"
"path/filepath"
"strconv"
"testing"
)

func TestReadServerPid(t *testing.T) {
path := filepath.Join(t.TempDir(), "server.pid")

pid, alive, err := readServerPid(path)
if err != nil || alive || pid != 0 {
t.Fatalf("missing file: got pid=%d alive=%v err=%v, want 0/false/nil", pid, alive, err)
}

if err := os.WriteFile(path, []byte("not-a-pid"), 0o644); err != nil {
t.Fatal(err)
}
pid, alive, err = readServerPid(path)
if err != nil || alive || pid != 0 {
t.Fatalf("malformed file: got pid=%d alive=%v err=%v, want 0/false/nil", pid, alive, err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("malformed pid file should be removed, stat err=%v", err)
}

if err := os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())), 0o644); err != nil {
t.Fatal(err)
}
pid, alive, err = readServerPid(path)
if err != nil || !alive || pid != os.Getpid() {
t.Fatalf("own pid: got pid=%d alive=%v err=%v, want %d/true/nil", pid, alive, err, os.Getpid())
}
}
7 changes: 5 additions & 2 deletions skills/ws-dev-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ the MCP server, use the **ws-dev-logs** skill.
shared git dir (`<git-common-dir>/ws-dev/`, inside `.git`, never committed and
shared by every worktree). Starting a new server **stops any prior one first**
(SIGTERM, then SIGKILL on timeout). Running two worktrees' servers in parallel
is not supported, to avoid port conflicts.
is not supported, to avoid port conflicts. `ws-dev status` reports whether a
server is currently running and for which worktree.
- **Worktree argument is optional and positional.** A worktree is resolved by
directory basename against `git worktree list`; an ambiguous basename is an
error. When omitted, commands default to the repository's **main worktree
Expand All @@ -38,6 +39,7 @@ the MCP server, use the **ws-dev-logs** skill.
| `ws-dev server [<worktree>]` | Start all `processes` in parallel in the worktree (stops any prior server first). Foreground; streams output. |
| `ws-dev server -b [<worktree>]` | Same, but **detached** — returns immediately; output goes to `<log-dir>/server.log`. |
| `ws-dev server stop` | Stop the currently running server. |
| `ws-dev status` | Show whether the server is running (pid) and for which worktree. |
| `ws-dev logs [<worktree>] [<name>]` | List `*.log`, or tail `<name>.log`. |
| `ws-dev run [<worktree>] <task> [args...]` | Run a task defined under `tasks:`; extra args pass through. |
| `ws-dev tasks` | List tasks defined for this repo. |
Expand Down Expand Up @@ -69,7 +71,8 @@ processes. A failing `setup` step aborts the start (see **Setup commands**).
another `ws-dev server` / `ws-dev server stop` arrives. `-b` re-execs itself in a
new session and exits; everything (including `setup`) then runs in the detached
child, with combined output in `server.log`. Per-process output still goes to
`<log-dir>/<name>.log`. Stop a background server with `ws-dev server stop`.
`<log-dir>/<name>.log`. Check a background server with `ws-dev status`; stop it
with `ws-dev server stop`.

### `ws-dev logs` — find and tail logs

Expand Down
Loading