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
8 changes: 4 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ binary:
# --- VS Code extension (editors/vscode) ---------------------------------

# A fresh .vsix from this worktree, carrying the CLI built alongside it.
vscode-build: binary
vscode-build:
rm -rf editors/vscode/bin && mkdir -p editors/vscode/bin
cp dist/binary/workforest editors/vscode/bin/workforest
cd editors/vscode && rm -f *.vsix && npm install --no-audit --no-fund
Expand All @@ -95,13 +95,13 @@ vscode-install:
vscode-uninstall:
code --uninstall-extension ArkadyBuryakov.workforest-vscode

vscode: vscode-build vscode-install
vscode: binary vscode-build vscode-install

# --- JetBrains plugin (editors/idea) ------------------------------------

# Only this machine's platform, so the zip is not the four-platform one CI
# builds; that is all a local install can run anyway.
idea-build: binary
idea-build:
rm -rf editors/idea/bin && mkdir -p editors/idea/bin/$(PLATFORM)
cp dist/binary/workforest editors/idea/bin/$(PLATFORM)/workforest
@$(STAMP); \
Expand Down Expand Up @@ -155,6 +155,6 @@ idea-uninstall:
rm -rf "$(IDEA_PLUGINS)/workforest-idea"
@echo "removed — restart the IDE"

idea: idea-build idea-install
idea: binary idea-build idea-install

plugins: vscode idea
69 changes: 57 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ wf open login -o 'lazygit' # open with any command instead
wf run test # run a named script from config
wf run -b backend # detached; `wf stop backend` ends it
wf run make check -j2 # extra args are appended to the script command
wf make check # any makefile target, tracked like a script
wf checkout login # fold the branch back into the main checkout
wf delete fix-y # remove a worktree (asks about dirty changes)
wf # interactive TUI (fzf)
Expand Down Expand Up @@ -119,6 +120,7 @@ symlinks: [] # untracked assets linked from main into new worktrees
setup_scripts: [] # shell snippets run in a fresh worktree
scripts: {} # name -> command or group for `wf run NAME` (see Scripts)
stop_timeout: 30 # seconds a stopped script gets after SIGTERM before SIGKILL
make: {} # which makefile targets `wf make` offers by name (see Makefile targets)
```

### Openers
Expand Down Expand Up @@ -315,6 +317,42 @@ Members cannot read the terminal, but Ctrl-C reaches them all. A bulk
waits for every member — so `bulk: [lint, test, typecheck]` shows every
failure, not just the first — and fails if any of them did.

### Makefile targets

Where `make` is installed and the worktree root holds a `GNUmakefile`,
`makefile`, or `Makefile`, every target it defines is runnable without
being configured:

```sh
wf make check # `make check` at the worktree root
wf make check -j4 # extra args are appended, as for `wf run`
wf make -b watch # detached, output to a log file
wf stop --make watch # stop it again
```

`wf make` is `wf run` with a synthesized entry: the same process group and
terminal handling, exit status, records, `exclusive` preemption and
cleanup. A target runs under the script name `make:TARGET` — which is what
job records, `wf stop --make`, and the `running` counts of `wf list --json`
show — so the `scripts` map is never shadowed.

The `make` section says which targets are *offered by name*, in shell
completion and in the editor plugins' script lists:

```yaml
make:
hidden: false # true: offer no target at all
hide_scripts: [install] # offer every target but these
show_scripts: [] # offer only these (wins over hide_scripts)
exclusive_scripts: [dev, watch] # starting one stops its running instances first
```

Hiding is about what is *offered*, not what may run: `wf make install`
still runs a hidden target. The targets are read out of the makefile (and
what it includes) rather than from `make` itself, so listing them never
evaluates a `$(shell ...)`; targets a build generates simply do not appear
in the list, and run just the same.

### Script environment

`setup_scripts`, `scripts`, and hooks run via `$SHELL -c` with:
Expand Down Expand Up @@ -368,7 +406,8 @@ workforest list [--porcelain | --json]
workforest delete NAME... [--force] [--delete-branch | --keep-branch]
workforest checkout NAME [--force]
workforest run [-b] SCRIPT [ARGS...]
workforest stop SCRIPT [--all]
workforest make [-b] TARGET [ARGS...]
workforest stop SCRIPT [--all] [--make]
workforest tui [MODE]
workforest init [--local]
workforest config [--json]
Expand Down Expand Up @@ -396,20 +435,23 @@ directives for the shell function, `--porcelain`/`--json` listings, dumps).
`list --json` describes the whole forest for programs — `main` (the main
checkout, in the same `name`/`branch`/`path`/`dirty`/`running` shape as each
entry of `worktrees`, `running` being an object mapping the name of each
script running there to how many instances of it run) and the resolved
`worktrees_dir` — and is what the editor extensions read.
script running there — a makefile target under `make:TARGET` — to how many
instances of it run) and the resolved `worktrees_dir` — and is what the
editor extensions read.

## JetBrains IDE plugin

`editors/idea/` holds a plugin for IntelliJ IDEA, PyCharm, WebStorm, and
the other IntelliJ-based IDEs (2025.2 or later) that puts the forest in the
IDE: a **Workforest** tool window with the project's scripts (badged
where they are running) and the main checkout plus the worktrees (most
IDE: a **Workforest** tool window with the project's scripts and makefile
targets (badged `make`, and where they are running) and the main checkout
plus the worktrees (most
recently opened first, dirty markers, the one this window is in), with
tooltips, inline buttons, and context menus; commands to create, open,
delete, and checkout worktrees (the last two on this window's worktree
when nothing is selected),
run and stop `scripts` in the IDE terminal, open a terminal in a worktree,
run and stop `scripts` and makefile targets in the IDE terminal, open a
terminal in a worktree,
show the merged configuration, and scaffold the project or the
`.idea/.workforest.yaml` local config; plus a status bar widget. It is a
thin client: every action runs the `workforest` command (`list --json`,
Expand Down Expand Up @@ -441,16 +483,19 @@ the `.idea/` carry-over recipe, troubleshooting).

`editors/vscode/` holds a VS Code extension that puts the forest in the
editor: a **Workforest** sidebar with the JetBrains plugin's toolbar in
its header and two collapsible sections, Scripts (run/stop with one
click, marked where they are running) and Worktrees (main checkout, then
its header and two collapsible sections, Scripts — the `scripts` entries
and the makefile targets, badged `make` — (run/stop with one click, marked
where they are running) and Worktrees (main checkout, then
managed worktrees by recency, dirty markers, the worktree this window is
in), commands to create, open, delete, and checkout worktrees (the last
two on this window's worktree when invoked on no row), run and stop `scripts` in
the integrated terminal, show the merged configuration, and scaffold the
two on this window's worktree when invoked on no row), run and stop
`scripts` and makefile targets in the integrated terminal, show the merged
configuration, and scaffold the
project or the `.vscode/.workforest.yaml` local config, plus a status bar
item. It is a thin client: every action runs the `workforest` command
(`list --json`, `config --json`, `--complete branches`, and the plain
subcommands with `--force`/`--keep-branch` in place of terminal prompts),
(`list --json`, `config --json`, `--complete branches`, `--complete make`,
and the plain subcommands with `--force`/`--keep-branch` in place of
terminal prompts),
so the editor and your shell always agree.

Install it from the Extensions view, or from the
Expand Down
3 changes: 3 additions & 0 deletions completions/_workforest
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ else
case "$cmd" in
create) topic=branches ;;
open|delete|checkout) topic=worktrees ;;
make) topic=make ;;
run) topic=scripts ;;
# `wf stop --make TARGET` names a makefile target, not a script.
stop) if (( ${words[(I)--make]} )); then topic=make; else topic=scripts; fi ;;
claude) topic=claude-sessions ;;
tui|list|init|config|shell-init) topic=none ;;
*) topic=worktrees ;;
Expand Down
12 changes: 7 additions & 5 deletions editors/idea/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ collapsible sections:

- **Scripts**: the `scripts` of this window's repository — a command, a
`bulk`, or a `pipeline` (each with its own icon), `background` /
`exclusive` flagged, the command in the tooltip. ▶ runs one in this
window's worktree (`workforest run NAME` in a new terminal tab), ■ stops
it; double-click runs. A running script wears a `●` per place, side by
`exclusive` flagged, the command in the tooltip — then, where `make` is
installed and the repository root holds a makefile, its targets, badged
`make`. ▶ runs one in this window's worktree (`workforest run NAME`, or
`workforest make TARGET`, in a new terminal tab), ■ stops it;
double-click runs. A running script wears a `●` per place, side by
side: light blue for this window's worktree, orange for the others,
each with the count of instances once there is more than one. The
tooltip says the same in words. The section follows
Expand Down Expand Up @@ -46,8 +48,8 @@ it) and only ask when this window is the main checkout.
| Open Worktree… | opens the main checkout or a worktree in a new window, this window, or asks — see the *Open worktrees in* setting. |
| Delete Worktree… | `workforest delete NAME --force` after its own confirmation for uncommitted changes, and asks whether to delete the branch. Without a selected row it targets the worktree this window is in. Deleting the worktree this window shows replaces the window with the main checkout. |
| Checkout into Main Checkout… | `workforest checkout NAME --force`: fold a worktree back into the main checkout — this window's, without a selected row; offers to open it when no window shows it. |
| Run Script… | `workforest run NAME` in a new terminal tab in the chosen worktree (this window's by default; from a worktree's context menu, that worktree), so Ctrl-C, colors, and background scripts behave exactly as in your shell. Needs the bundled Terminal plugin. |
| Stop Script… | `workforest stop NAME` in the chosen worktree. |
| Run Script… | `workforest run NAME` — or `workforest make TARGET` for a makefile target — in a new terminal tab in the chosen worktree (this window's by default; from a worktree's context menu, that worktree), so Ctrl-C, colors, and background scripts behave exactly as in your shell. Needs the bundled Terminal plugin. |
| Stop Script… | `workforest stop NAME` (`workforest stop --make TARGET` for a makefile target) in the chosen worktree. |
| Open in Terminal | a terminal tab in the worktree's directory. |
| Show Merged Configuration | `workforest config` in a read-only editor tab. |
| Initialize Project Config | `workforest init`: scaffolds `.workforest.yaml` and opens it. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,8 @@ class StopScriptAction : WorkforestAction() {
val project = e.project ?: return
val cwd = e.scriptCwd() ?: return
chooseScript(e, "Stop Script in ${cwd.fileName}") { script ->
runInBackground(project, "Stopping ${script.name}", work = { WorkforestCli.run(cwd, "stop", script.name) }) {
val args = if (script.isMake) arrayOf("stop", "--make", script.name) else arrayOf("stop", script.name)
runInBackground(project, "Stopping ${script.name}", work = { WorkforestCli.run(cwd, *args) }) {
WorkforestNotifications.info(project, "Stopped ${script.name} in ${cwd.fileName}")
}
}
Expand Down Expand Up @@ -304,6 +305,7 @@ fun scriptIcon(script: ScriptInfo): Icon = when (script.kind) {
ScriptKind.COMMAND -> AllIcons.Nodes.Console
ScriptKind.BULK -> AllIcons.Actions.GroupBy
ScriptKind.PIPELINE -> AllIcons.Actions.ListFiles
ScriptKind.MAKE -> AllIcons.Actions.Compile
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,36 @@ data class Forest(val main: Worktree, val worktreesDir: Path, val worktrees: Lis
/** One line of `workforest --complete branches`: a branch `create` accepts. */
data class BranchCandidate(val name: String, val location: String)

enum class ScriptKind { COMMAND, BULK, PIPELINE }
enum class ScriptKind { COMMAND, BULK, PIPELINE, MAKE }

/** A `scripts` entry of `config --json`. */
/** A `scripts` entry of `config --json`, or a makefile target of `--complete make`. */
data class ScriptInfo(
val name: String,
val name: String, // as `run NAME` / `make TARGET` takes it
val kind: ScriptKind,
val detail: String, // the command, or the members of a group
val background: Boolean,
val exclusive: Boolean,
) {
/** The flags worth showing next to the name: "background, exclusive". */
val isMake: Boolean get() = kind == ScriptKind.MAKE

/**
* The name it runs under: a makefile target's is `make:TARGET`, which
* is the key of the `running` counts and what a job record is filed as.
*/
val runningKey: String get() = if (isMake) "$MAKE_PREFIX$name" else name

/** The flags worth showing next to the name: "make, exclusive". */
val flags: String
get() = listOfNotNull("background".takeIf { background }, "exclusive".takeIf { exclusive }).joinToString(", ")
get() = listOfNotNull(
"make".takeIf { isMake },
"background".takeIf { background },
"exclusive".takeIf { exclusive },
).joinToString(", ")
}

/** The script-name prefix a makefile target runs under. */
const val MAKE_PREFIX = "make:"

object Protocol {
/** `{"main": {...}, "worktrees_dir": "...", "worktrees": [{...}]}`. */
fun parseForest(stdout: String): Forest = try {
Expand Down Expand Up @@ -92,6 +107,27 @@ object Protocol {
return ScriptInfo(name, kind, detail, background = flag("background"), exclusive = flag("exclusive"))
}

/**
* The makefile targets `make` offers here: the plain names of
* `--complete make` (the CLI has already applied the `make` config's
* `hidden`/`hide_scripts`/`show_scripts`), flagged `exclusive` from
* that config's `exclusive_scripts` in the same `config --json` dump.
*/
fun parseMakeScripts(completeStdout: String, configStdout: String): List<ScriptInfo> {
val exclusive = makeExclusive(configStdout)
return completeStdout.lineSequence().filter { it.isNotBlank() }
.map { ScriptInfo(it, ScriptKind.MAKE, "make $it", background = false, exclusive = it in exclusive) }
.toList()
}

private fun makeExclusive(configStdout: String): Set<String> = try {
val make = JsonParser.parseString(configStdout).asJsonObject.getAsJsonObject("config").get("make")
if (make == null || !make.isJsonObject) emptySet()
else make.asJsonObject.getAsJsonArray("exclusive_scripts").map { it.asString }.toSet()
} catch (e: RuntimeException) { // no `make` section, or a shape this plugin does not know
emptySet()
}

/** `NAME<TAB>LOCATION` per line; a bare name means an unknown location. */
fun parseBranches(stdout: String): List<BranchCandidate> =
stdout.lineSequence().filter { it.isNotBlank() }.map { line ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,17 @@ object WorkforestCli {
fun branchCandidates(cwd: Path): List<BranchCandidate> =
Protocol.parseBranches(run(cwd, "--complete", "branches").stdout)

/** The `scripts` of the merged config. */
fun scripts(cwd: Path): List<ScriptInfo> = Protocol.parseScripts(run(cwd, "config", "--json").stdout)
/**
* The `scripts` of the merged config, then the makefile targets
* `make` offers there (none where make or the makefile is missing).
*/
fun scripts(cwd: Path): List<ScriptInfo> {
val config = run(cwd, "config", "--json").stdout
return Protocol.parseScripts(config) + Protocol.parseMakeScripts(complete(cwd, "make"), config)
}

/** One `--complete` topic; it never fails, so an empty list is its error report. */
private fun complete(cwd: Path, topic: String): String = run(cwd, "--complete", topic).stdout

/** `workforest config`: the merged configuration as YAML with its sources. */
fun configDump(cwd: Path): String = run(cwd, "config").stdout
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ class WorktreePanel(private val project: Project) : SimpleToolWindowPanel(true,
}

/** Where [script] is running: how many instances here, and how many elsewhere. */
private fun runningOf(script: ScriptInfo): RunningState = RunningState.of(worktrees, script.name, here)
private fun runningOf(script: ScriptInfo): RunningState = RunningState.of(worktrees, script.runningKey, here)

private fun show(view: ForestView) {
worktrees = view.worktrees
Expand Down Expand Up @@ -345,7 +345,7 @@ class WorktreePanel(private val project: Project) : SimpleToolWindowPanel(true,

private fun identity(userObject: Any?): Any? = when (userObject) {
is Worktree -> userObject.path
is ScriptInfo -> "script:${userObject.name}"
is ScriptInfo -> "script:${userObject.runningKey}"
else -> userObject
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,21 @@ class RunScriptAction : WorkforestAction() {
override fun actionPerformed(e: AnActionEvent) {
val project = e.project ?: return
val cwd = e.scriptCwd() ?: return
chooseScript(e, "Run Script in ${cwd.fileName}") { runInTerminal(project, cwd, it.name) }
chooseScript(e, "Run Script in ${cwd.fileName}") {
runInTerminal(project, cwd, it.name, if (it.isMake) "make" else "run")
}
}

private fun runInTerminal(project: Project, cwd: Path, script: String) {
private fun runInTerminal(project: Project, cwd: Path, script: String, verb: String) {
val executable = try {
WorkforestCli.executable()
} catch (e: WorkforestException) {
WorkforestNotifications.error(project, e)
return
}
val widget = TerminalToolWindowManager.getInstance(project)
.createShellWidget(cwd.toString(), "wf run $script", true, true)
widget.sendCommandToExecute("${Protocol.shellQuote(executable.toString())} run ${Protocol.shellQuote(script)}")
.createShellWidget(cwd.toString(), "wf $verb $script", true, true)
widget.sendCommandToExecute("${Protocol.shellQuote(executable.toString())} $verb ${Protocol.shellQuote(script)}")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,30 @@ class ProtocolTest {
assertEquals(true, error.message!!.startsWith("unexpected `config --json` output"))
}

@Test
fun parsesMakeTargets() {
val config = """{"config": {"make": {"exclusive_scripts": ["dev"]}}, "sources": []}"""
val scripts = Protocol.parseMakeScripts("check\ndev\n", config)
assertEquals(
listOf(
ScriptInfo("check", ScriptKind.MAKE, "make check", background = false, exclusive = false),
ScriptInfo("dev", ScriptKind.MAKE, "make dev", background = false, exclusive = true),
),
scripts,
)
assertEquals("make:check", scripts[0].runningKey)
assertEquals("make", scripts[0].flags)
assertEquals("make, exclusive", scripts[1].flags)
}

@Test
fun makeTargetsMissingOrMalformed() {
assertEquals(emptyList<ScriptInfo>(), Protocol.parseMakeScripts("", """{"config": {}}"""))
// no `make` section, or output we cannot read: no target is exclusive
assertEquals(false, Protocol.parseMakeScripts("check\n", """{"config": {}}""")[0].exclusive)
assertEquals(false, Protocol.parseMakeScripts("check\n", "nope")[0].exclusive)
}

@Test
fun bookkeepingPaths() {
assertEquals(true, WorktreeService.isBookkeeping("/r/.git/worktrees/feat"))
Expand Down
Loading
Loading