From fd191649904d7c5dc0a24ba921f1f75bc86c87d6 Mon Sep 17 00:00:00 2001 From: Arkady Buryakov Date: Tue, 8 Sep 2026 20:52:15 +0300 Subject: [PATCH] Makefile script support --- Makefile | 8 +- README.md | 69 ++++- completions/_workforest | 3 + editors/idea/README.md | 12 +- .../pro/buryakov/workforest/idea/Actions.kt | 4 +- .../pro/buryakov/workforest/idea/Protocol.kt | 46 +++- .../buryakov/workforest/idea/WorkforestCli.kt | 13 +- .../workforest/idea/WorkforestToolWindow.kt | 4 +- .../idea/terminal/RunScriptAction.kt | 10 +- .../buryakov/workforest/idea/ProtocolTest.kt | 24 ++ editors/vscode/README.md | 12 +- editors/vscode/src/commands.ts | 22 +- editors/vscode/src/forest.ts | 46 +++- editors/vscode/src/model.ts | 9 +- editors/vscode/src/test/forest.test.ts | 28 ++ editors/vscode/src/tree.ts | 6 +- man/workforest.1 | 64 ++++- man/workforest.5 | 34 +++ pyproject.toml | 8 + src/workforest/cli.py | 27 +- src/workforest/commands.py | 35 ++- src/workforest/completions.py | 14 +- src/workforest/config.py | 65 ++++- src/workforest/hooks.py | 16 +- src/workforest/makefile.py | 136 ++++++++++ src/workforest/shell/completion.bash | 5 +- src/workforest/shell/completion.zsh | 5 +- src/workforest/templates/project.yaml | 6 + tests/test_makefile.py | 250 ++++++++++++++++++ 29 files changed, 907 insertions(+), 74 deletions(-) create mode 100644 src/workforest/makefile.py create mode 100644 tests/test_makefile.py diff --git a/Makefile b/Makefile index 40d42e4..7eff9e4 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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); \ @@ -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 diff --git a/README.md b/README.md index fa0c572..9bdc267 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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: @@ -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] @@ -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`, @@ -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 diff --git a/completions/_workforest b/completions/_workforest index 832f6c4..c136bf1 100644 --- a/completions/_workforest +++ b/completions/_workforest @@ -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 ;; diff --git a/editors/idea/README.md b/editors/idea/README.md index 4bc1b7f..1bedb86 100644 --- a/editors/idea/README.md +++ b/editors/idea/README.md @@ -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 @@ -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. | diff --git a/editors/idea/src/main/kotlin/pro/buryakov/workforest/idea/Actions.kt b/editors/idea/src/main/kotlin/pro/buryakov/workforest/idea/Actions.kt index 164d441..938f29e 100644 --- a/editors/idea/src/main/kotlin/pro/buryakov/workforest/idea/Actions.kt +++ b/editors/idea/src/main/kotlin/pro/buryakov/workforest/idea/Actions.kt @@ -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}") } } @@ -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 } /** diff --git a/editors/idea/src/main/kotlin/pro/buryakov/workforest/idea/Protocol.kt b/editors/idea/src/main/kotlin/pro/buryakov/workforest/idea/Protocol.kt index 838574b..a3924c0 100644 --- a/editors/idea/src/main/kotlin/pro/buryakov/workforest/idea/Protocol.kt +++ b/editors/idea/src/main/kotlin/pro/buryakov/workforest/idea/Protocol.kt @@ -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 { @@ -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 { + 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 = 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() + } + /** `NAMELOCATION` per line; a bare name means an unknown location. */ fun parseBranches(stdout: String): List = stdout.lineSequence().filter { it.isNotBlank() }.map { line -> diff --git a/editors/idea/src/main/kotlin/pro/buryakov/workforest/idea/WorkforestCli.kt b/editors/idea/src/main/kotlin/pro/buryakov/workforest/idea/WorkforestCli.kt index 6cbf819..d288c71 100644 --- a/editors/idea/src/main/kotlin/pro/buryakov/workforest/idea/WorkforestCli.kt +++ b/editors/idea/src/main/kotlin/pro/buryakov/workforest/idea/WorkforestCli.kt @@ -121,8 +121,17 @@ object WorkforestCli { fun branchCandidates(cwd: Path): List = Protocol.parseBranches(run(cwd, "--complete", "branches").stdout) - /** The `scripts` of the merged config. */ - fun scripts(cwd: Path): List = 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 { + 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 diff --git a/editors/idea/src/main/kotlin/pro/buryakov/workforest/idea/WorkforestToolWindow.kt b/editors/idea/src/main/kotlin/pro/buryakov/workforest/idea/WorkforestToolWindow.kt index 02872f1..3825af8 100644 --- a/editors/idea/src/main/kotlin/pro/buryakov/workforest/idea/WorkforestToolWindow.kt +++ b/editors/idea/src/main/kotlin/pro/buryakov/workforest/idea/WorkforestToolWindow.kt @@ -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 @@ -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 } diff --git a/editors/idea/src/main/kotlin/pro/buryakov/workforest/idea/terminal/RunScriptAction.kt b/editors/idea/src/main/kotlin/pro/buryakov/workforest/idea/terminal/RunScriptAction.kt index ac749fa..d04dc39 100644 --- a/editors/idea/src/main/kotlin/pro/buryakov/workforest/idea/terminal/RunScriptAction.kt +++ b/editors/idea/src/main/kotlin/pro/buryakov/workforest/idea/terminal/RunScriptAction.kt @@ -20,10 +20,12 @@ 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) { @@ -31,8 +33,8 @@ class RunScriptAction : WorkforestAction() { 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)}") } } diff --git a/editors/idea/src/test/kotlin/pro/buryakov/workforest/idea/ProtocolTest.kt b/editors/idea/src/test/kotlin/pro/buryakov/workforest/idea/ProtocolTest.kt index 2c98b1c..3a3cac6 100644 --- a/editors/idea/src/test/kotlin/pro/buryakov/workforest/idea/ProtocolTest.kt +++ b/editors/idea/src/test/kotlin/pro/buryakov/workforest/idea/ProtocolTest.kt @@ -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(), 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")) diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 8d4da64..33d0810 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -47,9 +47,11 @@ act on, except Checkout and Delete, which act on the worktree this window is in (after confirming it) and only ask from the main checkout. Below it, two collapsible sections: -- **Scripts**: the `scripts` of this window's repository, with their - command in the tooltip. ▶ on a row runs it in this window's worktree - (`workforest run NAME` in a new integrated terminal), ■ stops it. A +- **Scripts**: the `scripts` of this window's repository, then — where + `make` is installed and the repository root holds a makefile — its + targets, badged `make`, with their command in the tooltip. ▶ on a row + runs it in this window's worktree (`workforest run NAME`, or + `workforest make TARGET`, in a new integrated terminal), ■ stops it. A running script's icon turns light blue while it runs in this window's worktree and orange while it runs only in others, with every instance counted next to the name (`1 here`, `2 here, 3 elsewhere`) and spelled @@ -72,8 +74,8 @@ it, two collapsible sections: | Open Worktree… | opens the main checkout or a worktree in a new window, this window, or asks — see `workforest.openIn`. | | Delete Worktree… | `workforest delete NAME... --force` after its own confirmation for uncommitted changes, and asks whether to delete the branch. Invoked from the header or the palette it targets the worktree this window is in; from the tree, the row it was invoked on. Deleting the worktree this window shows moves the window to the main checkout. | | Checkout into Main Checkout… | `workforest checkout NAME --force`: fold a worktree back into the main checkout — this window's, unless invoked on a row. | -| Run Script… | `workforest run NAME` in a new integrated terminal 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. | -| 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 integrated terminal 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. | +| Stop Script… | `workforest stop NAME` (`workforest stop --make TARGET` for a makefile target) in the chosen worktree. | | Open in Integrated Terminal | a terminal in the worktree's directory. | | Show Merged Configuration | `workforest config` in an editor tab (also in the Scripts view's `…` menu). | | Initialize Project Config | `workforest init`: scaffolds `.workforest.yaml` and opens it. | diff --git a/editors/vscode/src/commands.ts b/editors/vscode/src/commands.ts index ff2b110..7a87384 100644 --- a/editors/vscode/src/commands.ts +++ b/editors/vscode/src/commands.ts @@ -15,13 +15,14 @@ import { ScriptInfo, WorktreeInfo, parseCandidates, + parseMakeScripts, parseScripts, scriptDescription, shellQuote, worktreeNameFor, } from './forest'; import { ForestModel } from './model'; -import { EntryNode, ForestNode, Node, ScriptNode } from './tree'; +import { EntryNode, ForestNode, Node, SCRIPT_ICONS, ScriptNode } from './tree'; export interface Deps { cli: Cli; @@ -440,8 +441,15 @@ interface ScriptItem extends vscode.QuickPickItem { script: ScriptInfo; } +/** The `scripts` entries plus the makefile targets `wf make` offers here. */ +async function loadScripts(deps: Deps, cwd: string): Promise { + const configJson = await deps.cli.expect(['config', '--json'], cwd, 'reading the configuration'); + const make = await deps.cli.run(['--complete', 'make'], cwd); + return [...parseScripts(configJson), ...(make.code === 0 ? parseMakeScripts(make.stdout, configJson) : [])]; +} + async function pickScript(deps: Deps, cwd: string, placeHolder: string): Promise { - const scripts = parseScripts(await deps.cli.expect(['config', '--json'], cwd, 'reading the configuration')); + const scripts = await loadScripts(deps, cwd); if (scripts.length === 0) { const choice = await vscode.window.showInformationMessage( 'Workforest: no scripts are configured. Add a `scripts` entry to .workforest.yaml.', @@ -454,7 +462,7 @@ async function pickScript(deps: Deps, cwd: string, placeHolder: string): Promise } const picked = await vscode.window.showQuickPick( scripts.map((script) => ({ - label: `$(${script.kind === 'command' ? 'terminal' : script.kind === 'bulk' ? 'layers' : 'list-ordered'}) ${script.name}`, + label: `$(${SCRIPT_ICONS[script.kind]}) ${script.name}`, description: scriptDescription(script), detail: script.detail, script, @@ -505,8 +513,9 @@ export async function runScript(deps: Deps, node?: Node): Promise { return; } // The user's shell runs it: Ctrl-C, colors, and a tty exactly like `wf run`. - const terminal = vscode.window.createTerminal({ name: `wf run ${script.name}`, cwd: target.info.path }); - terminal.sendText(`${shellQuote(deps.cli.executable)} run ${shellQuote(script.name)}`, true); + const verb = script.kind === 'make' ? 'make' : 'run'; + const terminal = vscode.window.createTerminal({ name: `wf ${verb} ${script.name}`, cwd: target.info.path }); + terminal.sendText(`${shellQuote(deps.cli.executable)} ${verb} ${shellQuote(script.name)}`, true); terminal.show(); }); } @@ -521,7 +530,8 @@ export async function stopScript(deps: Deps, node?: Node): Promise { if (!script) { return; } - await deps.cli.expect(['stop', script.name], target.info.path, `stopping ${script.name}`); + const stop = script.kind === 'make' ? ['stop', '--make', script.name] : ['stop', script.name]; + await deps.cli.expect(stop, target.info.path, `stopping ${script.name}`); void vscode.window.showInformationMessage(`Workforest: stopped ${script.name} in ${target.info.name}.`); }); } diff --git a/editors/vscode/src/forest.ts b/editors/vscode/src/forest.ts index 06b99c9..df3888e 100644 --- a/editors/vscode/src/forest.ts +++ b/editors/vscode/src/forest.ts @@ -111,14 +111,17 @@ export function parseCandidates(text: string): BranchCandidate[] { }); } -export type ScriptKind = 'command' | 'bulk' | 'pipeline'; +export type ScriptKind = 'command' | 'bulk' | 'pipeline' | 'make'; export interface ScriptInfo { - name: string; + name: string; // as `wf run NAME` / `wf make TARGET` takes it kind: ScriptKind; detail: string; // the command, or the members of a group background: boolean; exclusive: boolean; + /** The name it runs under — a make target's is `make:TARGET`: the key of + * the `running` counts, and what a job record is filed as. */ + runningKey: string; } /** The `scripts` map of `workforest config --json`, sorted by name; `hidden` entries are left out. */ @@ -142,13 +145,14 @@ function isHidden(entry: unknown): boolean { } function scriptInfo(name: string, entry: unknown): ScriptInfo { + const runningKey = name; if (typeof entry === 'string') { - return { name, kind: 'command', detail: entry, background: false, exclusive: false }; + return { name, kind: 'command', detail: entry, background: false, exclusive: false, runningKey }; } if (!isRecord(entry)) { throw new Error(`scripts.${name}: unexpected shape`); } - const flags = { background: entry.background === true, exclusive: entry.exclusive === true }; + const flags = { background: entry.background === true, exclusive: entry.exclusive === true, runningKey }; if (Array.isArray(entry.bulk)) { return { name, kind: 'bulk', detail: `bulk: ${entry.bulk.join(', ')}`, ...flags }; } @@ -158,9 +162,43 @@ function scriptInfo(name: string, entry: unknown): ScriptInfo { return { name, kind: 'command', detail: String(entry.command ?? ''), ...flags }; } +/** + * The makefile targets `wf 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`. They run under `make:TARGET`, which is the + * name `list --json` counts them by. + */ +export function parseMakeScripts(completeOutput: string, configJson: string): ScriptInfo[] { + const exclusive = new Set(makeExclusive(configJson)); + return completeOutput + .split('\n') + .filter((name) => name.length > 0) + .map((name) => ({ + name, + kind: 'make' as const, + detail: `make ${name}`, + background: false, + exclusive: exclusive.has(name), + runningKey: `make:${name}`, + })); +} + +function makeExclusive(configJson: string): string[] { + const data: unknown = JSON.parse(configJson); + if (!isRecord(data) || !isRecord(data.config) || !isRecord(data.config.make)) { + return []; + } + const names = data.config.make.exclusive_scripts; + return Array.isArray(names) ? names.filter((name): name is string => typeof name === 'string') : []; +} + /** The flags worth showing next to a script's name in a picker. */ export function scriptDescription(script: ScriptInfo): string { const marks = []; + if (script.kind === 'make') { + marks.push('make'); + } if (script.background) { marks.push('background'); } diff --git a/editors/vscode/src/model.ts b/editors/vscode/src/model.ts index 5bb072d..ae796d2 100644 --- a/editors/vscode/src/model.ts +++ b/editors/vscode/src/model.ts @@ -18,6 +18,7 @@ import { locate, orderByRecency, parseForest, + parseMakeScripts, parseScripts, } from './forest'; import { Recency, createdAt } from './recency'; @@ -168,14 +169,20 @@ export class ForestModel implements vscode.Disposable { ); } + /** The forest's `scripts`, then the makefile targets `wf make` offers + * there (none where make or the makefile is missing). */ private async scriptsIn(forest: Forest): Promise { const result = await this.cli.run(['config', '--json'], forest.main.path); if (result.code !== 0) { this.log.appendLine(`config --json failed in ${forest.main.path} (exit ${result.code})`); return []; } + const make = await this.cli.run(['--complete', 'make'], forest.main.path); try { - return parseScripts(result.stdout); + return [ + ...parseScripts(result.stdout), + ...(make.code === 0 ? parseMakeScripts(make.stdout, result.stdout) : []), + ]; } catch (error) { this.log.appendLine(`config --json: ${String(error)}`); return []; diff --git a/editors/vscode/src/test/forest.test.ts b/editors/vscode/src/test/forest.test.ts index f0285f0..3885d1d 100644 --- a/editors/vscode/src/test/forest.test.ts +++ b/editors/vscode/src/test/forest.test.ts @@ -9,6 +9,7 @@ import { orderByRecency, parseCandidates, parseForest, + parseMakeScripts, parseScripts, runningLabel, runningNote, @@ -144,6 +145,33 @@ test('parseScripts flattens every entry form, sorted', () => { assert.deepEqual(parseScripts('{"config": {}}'), []); }); +test('parseMakeScripts turns --complete make lines into make-kind scripts', () => { + const configJson = JSON.stringify({ config: { make: { exclusive_scripts: ['dev'] } }, sources: [] }); + const scripts = parseMakeScripts('check\ndev\n', configJson); + assert.deepEqual( + scripts.map((s) => [s.name, s.kind, s.detail, s.runningKey, scriptDescription(s)]), + [ + ['check', 'make', 'make check', 'make:check', 'make'], + ['dev', 'make', 'make dev', 'make:dev', 'make, exclusive'], + ], + ); + assert.deepEqual(parseMakeScripts('', configJson), []); + // no `make` section, or none of the shapes we expect: no target is exclusive + assert.equal(parseMakeScripts('check\n', '{"config": {}}')[0]?.exclusive, false); +}); + +test('runningState counts a make target by its make: key', () => { + const forest = parseForest( + JSON.stringify({ + main: { name: 'api', branch: 'main', path: '/m', dirty: false, running: { 'make:check': 2 } }, + worktrees_dir: '/w', + worktrees: [], + }), + ); + const script = parseMakeScripts('check\n', '{"config": {}}')[0]!; + assert.deepEqual(runningState(forest, script.runningKey, '/m'), { here: 2, others: 0, otherWorktrees: 0 }); +}); + test('failureMessage takes the last stderr line without the prefix', () => { assert.equal(failureMessage("created worktree\nError: worktree 'x' not found\n", 'fb'), "worktree 'x' not found"); assert.equal(failureMessage(' \n', 'fallback'), 'fallback'); diff --git a/editors/vscode/src/tree.ts b/editors/vscode/src/tree.ts index f894296..48abf4b 100644 --- a/editors/vscode/src/tree.ts +++ b/editors/vscode/src/tree.ts @@ -170,10 +170,11 @@ export class ForestTree implements vscode.TreeDataProvider { const SECTION_TITLES: Record = { scripts: 'Scripts', worktrees: 'Worktrees' }; -const SCRIPT_ICONS: Record = { +export const SCRIPT_ICONS: Record = { command: 'terminal', bulk: 'layers', pipeline: 'list-ordered', + make: 'tools', }; /** @@ -188,7 +189,7 @@ const SCRIPT_ICONS: Record = { function scriptItem(node: ScriptNode, herePath: string | undefined): vscode.TreeItem { const { script } = node; const item = new vscode.TreeItem(script.name, vscode.TreeItemCollapsibleState.None); - const state = runningState(node.forest, script.name, herePath); + const state = runningState(node.forest, script.runningKey, herePath); item.description = [runningNote(state), scriptDescription(script)].filter((part) => part.length > 0).join(' · '); item.iconPath = new vscode.ThemeIcon(SCRIPT_ICONS[script.kind], runningColor(state)); item.contextValue = 'script'; @@ -196,6 +197,7 @@ function scriptItem(node: ScriptNode, herePath: string | undefined): vscode.Tree [ `**${script.name}**`, `\`\`\`sh\n${script.detail}\n\`\`\``, + script.kind === 'make' ? 'makefile target' : '', script.background ? 'background' : '', script.exclusive ? 'exclusive' : '', runningLabel(state), diff --git a/man/workforest.1 b/man/workforest.1 index 6c66175..df40dbd 100644 --- a/man/workforest.1 +++ b/man/workforest.1 @@ -47,9 +47,15 @@ workforest, wf \- git worktree forest management .I SCRIPT .RI [ ARGS .\|.\|.] .YS +.SY "workforest make" +.RB [ \-b ] +.I TARGET +.RI [ ARGS .\|.\|.] +.YS .SY "workforest stop" .I SCRIPT .RB [ \-\-all ] +.RB [ \-\-make ] .YS .SY "workforest tui" .RI [ MODE ] @@ -295,7 +301,56 @@ Must precede .IR SCRIPT ; after it, it belongs to .IR ARGS . -.SS stop \fISCRIPT\fR [\fB\-\-all\fR] +.SS make [\fB\-b\fR] \fITARGET\fR [\fIARGS\fR...] +Run +.BI "make " TARGET +from the root of the current worktree, exactly as +.BR run (above) +runs a +.B scripts +entry: the same process group, terminal handling, exit status, +.B \-b +flag, records, and +.BR stop . +Extra arguments are shell-quoted and appended, so +.B "wf make check \-j4" +runs +.BR "make check \-j4" . +Available where +.BR make (1) +is installed and the worktree root holds a +.BR GNUmakefile ", " makefile ", or " Makefile . +.PP +A target runs under the script name +.BI make: TARGET \fR, +which is what +.BR "list \-\-json" , +the job records, and +.B "stop \-\-make" +use; the +.B scripts +map is never shadowed. +The +.B make +configuration section (see +.BR workforest (5)) +decides which targets are offered by name in completions and the editor +plugins, and which of them are +.BR exclusive ; +it never restricts this command, which passes +.I TARGET +to +.BR make (1) +as given. +.TP +.BR \-b ", " \-\-background +As for +.BR run . +Must precede +.IR TARGET ; +after it, it belongs to +.IR ARGS . +.SS stop \fISCRIPT\fR [\fB\-\-all\fR] [\fB\-\-make\fR] Stop every running instance of the script in the current worktree \(em foreground or background, started from any terminal \(em with SIGTERM, then SIGKILL after @@ -308,6 +363,13 @@ Fails when nothing is running. .TP .B \-\-all Every worktree's instances of the script, not just this one's. +.TP +.B \-\-make +.I SCRIPT +is a makefile target: stop +.BI make: SCRIPT +instead (see +.BR make ). .SS tui [\fIMODE\fR] Interactive mode, driven by .BR fzf (1). diff --git a/man/workforest.5 b/man/workforest.5 index 37e3b49..30e52ca 100644 --- a/man/workforest.5 +++ b/man/workforest.5 @@ -81,6 +81,7 @@ symlinks: [] setup_scripts: [] scripts: {} stop_timeout: 30 +make: {} .EE .TP .B worktrees_dir @@ -261,6 +262,39 @@ and for .B exclusive preemption. A number above 0; default 30. +.TP +.B make +Which of the makefile's targets +.B "workforest make" +(see +.BR workforest (1)) +offers by name \(em in shell completion and in the script lists of the +editor plugins \(em and how they run. +The command itself is never restricted by these keys: a hidden target +still runs when named. +Only meaningful where +.BR make (1) +is installed and the worktree root holds a makefile. +.RS +.TP +.B hidden +Offer no target at all. +Default false. +.TP +.B hide_scripts +A list of target names to leave out. +.TP +.B show_scripts +A list of the only target names to offer; wins over +.BR hide_scripts . +.TP +.B exclusive_scripts +A list of target names that are +.B exclusive +(see +.BR scripts ): +starting one stops every running instance of it in the project first. +.RE .SH OPENERS AND WRAPPERS An .I opener diff --git a/pyproject.toml b/pyproject.toml index 7e46239..6ac5ab8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,14 @@ dev = [ "types-pyyaml>=6.0", ] +# What makes a build of this project stale. uv's default is this file +# alone, so `uv run --with .` — which is how packaging/binary/build.sh +# freezes the CLI the editor plugins ship — reused a wheel built before the +# last edit to src/, and PyInstaller froze that: a plugin bundling a CLI +# older than its own checkout. +[tool.uv] +cache-keys = [{ file = "pyproject.toml" }, { file = "src/**/*" }] + [tool.hatch.version] path = "src/workforest/__init__.py" diff --git a/src/workforest/cli.py b/src/workforest/cli.py index fa39991..bbb4214 100644 --- a/src/workforest/cli.py +++ b/src/workforest/cli.py @@ -22,6 +22,7 @@ "delete": "delete worktree(s)", "checkout": "delete a worktree and check its branch out in main", "run": "run a named script from the merged config", + "make": "run a makefile target (like `make TARGET` at the worktree root)", "stop": "stop a running script (this worktree's instances, or --all)", "tui": "interactive mode (requires fzf)", "init": "write a commented .workforest.yaml starter", @@ -106,9 +107,14 @@ def _handle_run(ns: argparse.Namespace) -> CommandResult: return commands.cmd_run(ctx, ns.script, ns.args, background=True if ns.background else None) +def _handle_make(ns: argparse.Namespace) -> CommandResult: + ctx = commands.build_context() + return commands.cmd_make(ctx, ns.target, ns.args, background=True if ns.background else None) + + def _handle_stop(ns: argparse.Namespace) -> CommandResult: ctx = commands.build_context() - return commands.cmd_stop(ctx, ns.script, everywhere=ns.all) + return commands.cmd_stop(ctx, ns.script, everywhere=ns.all, make=ns.make) def _handle_init(ns: argparse.Namespace) -> CommandResult: @@ -213,11 +219,30 @@ def opener_args(p: argparse.ArgumentParser) -> None: ) p.set_defaults(func=_handle_run) + p = sub.add_parser("make", help=SUBCOMMAND_HELP["make"]) + p.add_argument( + "-b", + "--background", + action="store_true", + help="detach, with output to a log file (before TARGET; after it, it belongs to ARGS)", + ) + p.add_argument("target", metavar="TARGET") + p.add_argument( + "args", + nargs=argparse.REMAINDER, + metavar="ARGS", + help="appended (shell-quoted) to the make command", + ) + p.set_defaults(func=_handle_make) + p = sub.add_parser("stop", help=SUBCOMMAND_HELP["stop"]) p.add_argument("script", metavar="SCRIPT") p.add_argument( "--all", action="store_true", help="every worktree's instances, not just this one's" ) + p.add_argument( + "--make", action="store_true", help="SCRIPT is a makefile target (`make:SCRIPT`)" + ) p.set_defaults(func=_handle_stop) p = sub.add_parser("init", help=SUBCOMMAND_HELP["init"]) diff --git a/src/workforest/commands.py b/src/workforest/commands.py index 6a58b68..7b7cafa 100644 --- a/src/workforest/commands.py +++ b/src/workforest/commands.py @@ -10,7 +10,7 @@ import yaml -from workforest import gitutil, hooks, launch, output +from workforest import gitutil, hooks, launch, makefile, output from workforest.config import ( PROJECT_LOCAL_DIRS, Config, @@ -354,9 +354,38 @@ def cmd_run( return None -def cmd_stop(ctx: Context, name: str, *, everywhere: bool = False) -> CommandResult: +def cmd_make( + ctx: Context, + target: str, + extra_args: list[str] | None = None, + *, + background: bool | None = None, +) -> CommandResult: + """`wf make TARGET`: `make TARGET` at the worktree root, run as the + script `make:TARGET` so it is recorded, stoppable and counted like any + other. The target is make's to accept or reject — a hidden one still + runs, and one this build generates is never in our list.""" + makefile.require(ctx.cwd_root) + hooks.run_named_script( + ctx.config, + makefile.script_name(target), + cwd=ctx.cwd_root, + env=_current_script_env(ctx), + extra_args=extra_args, + background=background, + ) + return None + + +def cmd_stop( + ctx: Context, name: str, *, everywhere: bool = False, make: bool = False +) -> CommandResult: hooks.stop_script( - ctx.config, name, cwd=ctx.cwd_root, env=_current_script_env(ctx), everywhere=everywhere + ctx.config, + makefile.script_name(name) if make else name, + cwd=ctx.cwd_root, + env=_current_script_env(ctx), + everywhere=everywhere, ) return None diff --git a/src/workforest/completions.py b/src/workforest/completions.py index b045838..f703905 100644 --- a/src/workforest/completions.py +++ b/src/workforest/completions.py @@ -6,11 +6,11 @@ shells that can render descriptions (zsh) do, while others take field 1. """ -from workforest import commands, gitutil, launch +from workforest import commands, gitutil, launch, makefile from workforest.config import Config, load_config from workforest.errors import WorkforestError -TOPICS = ("commands", "branches", "worktrees", "scripts", "openers", "claude-sessions") +TOPICS = ("commands", "branches", "worktrees", "scripts", "make", "openers", "claude-sessions") def complete(topic: str) -> list[str]: @@ -30,6 +30,8 @@ def _complete(topic: str) -> list[str]: return _worktrees() case "scripts": return sorted(name for name, spec in _config().scripts.items() if not spec.hidden) + case "make": + return _make() case "openers": return _openers() case "claude-sessions": @@ -51,6 +53,14 @@ def _commands() -> list[str]: return [f"{name}\t{SUBCOMMAND_HELP[name]}" for name in sorted(_known_subcommands())] +def _make() -> list[str]: + """The makefile targets `wf make` offers here, in the makefile's own + order (its default goal first); empty where make or the makefile is + missing, or the `make` config hides them.""" + ctx = commands.build_context() + return makefile.visible_targets(ctx.config, ctx.cwd_root) + + def _openers() -> list[str]: config = _config() return [ diff --git a/src/workforest/config.py b/src/workforest/config.py index a0fc708..97ebb4a 100644 --- a/src/workforest/config.py +++ b/src/workforest/config.py @@ -67,7 +67,20 @@ def members(self) -> tuple[str, ...]: return self.bulk or self.pipeline or () -type ConfigEntry = CommandSpec | OpenerSpec | ScriptSpec +@dataclass(slots=True, frozen=True) +class MakeSpec: + """The `make` section: which of the makefile's targets `wf make` offers + by name, and which of them may run only once at a time. Hiding a target + keeps it out of completions and the editor lists; `wf make` runs it all + the same.""" + + hidden: bool = False # offer no target at all + hide_scripts: tuple[str, ...] = () # offer every target but these + show_scripts: tuple[str, ...] = () # offer only these (wins over hide_scripts) + exclusive_scripts: tuple[str, ...] = () # starting one stops its running instances + + +type ConfigEntry = CommandSpec | OpenerSpec | ScriptSpec # a `map` entry def _field_name(key: str) -> str: @@ -81,13 +94,15 @@ def _key_name(field_name: str) -> str: @dataclass(slots=True, frozen=True) class _FieldSpec: - """Kinds: "str", "number", "list", "map" (a null value deletes the - inherited entry during merge). Map entries normalize to the `entry` dataclass; a string - is shorthand for `{command: }`.""" + """Kinds: "str", "number", "list", "map" (name → entry) and "section" + (a fixed set of keys) — both mappings, so a null value deletes the + inherited key during merge. Map entries normalize to the `entry` + dataclass; a string is shorthand for `{command: }`. The one + section, `make`, normalizes as a whole to MakeSpec in load_config.""" kind: str default: Any - entry: type[ConfigEntry] | None = None + entry: type[ConfigEntry] | None = None # `map` kinds only _SCHEMA: dict[str, _FieldSpec] = { @@ -99,6 +114,7 @@ class _FieldSpec: "setup_scripts": _FieldSpec("list", []), "scripts": _FieldSpec("map", {}, ScriptSpec), "stop_timeout": _FieldSpec("number", 30.0), + "make": _FieldSpec("section", {}), } @@ -118,6 +134,7 @@ class Config: setup_scripts: list[str] = field(default_factory=list) scripts: dict[str, ScriptSpec] = field(default_factory=dict) stop_timeout: float = 30.0 # seconds a stopped script gets between SIGTERM and SIGKILL + make: MakeSpec = field(default_factory=MakeSpec) sources: list[ConfigSource] = field(default_factory=list) def as_dict(self) -> dict[str, Any]: @@ -130,6 +147,12 @@ def as_dict(self) -> dict[str, Any]: "setup_scripts": self.setup_scripts, "scripts": {name: _entry_data(spec) for name, spec in self.scripts.items()}, "stop_timeout": self.stop_timeout, + "make": { + _key_name(f.name): ( + list(value) if isinstance(value := getattr(self.make, f.name), tuple) else value + ) + for f in fields(MakeSpec) + }, } @@ -218,6 +241,27 @@ def _validate(data: dict[str, Any], path: Path) -> None: raise ConfigError(f"{path}: {key!r} must be a mapping") for name, entry in value.items(): _validate_entry(entry, key=key, name=name, spec=_SCHEMA[key], path=path) + case "section": + _validate_section(value, key=key, path=path) + + +def _validate_section(value: Any, *, key: str, path: Path) -> None: + """The `make` section — the only one: a fixed set of keys, each shaped + like its MakeSpec default, a flag or a list of names; a null value + resets the key to that default during merge.""" + if not isinstance(value, dict) or not all(isinstance(k, str) for k in value): + raise ConfigError(f"{path}: {key!r} must be a mapping") + known = {f.name: f.default for f in fields(MakeSpec)} + for name, entry in value.items(): + if name not in known: + raise ConfigError(f"{path}: {key}.{name}: unknown key (known keys: {', '.join(known)})") + if entry is None: + continue + if isinstance(known[name], bool): + if not isinstance(entry, bool): + raise ConfigError(f"{path}: {key}.{name} must be true or false") + elif not isinstance(entry, list) or not all(isinstance(item, str) for item in entry): + raise ConfigError(f"{path}: {key}.{name} must be a list of strings") def _is_positive_number(value: Any) -> bool: @@ -333,7 +377,7 @@ def _merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]: """Scalars/lists replace; mappings merge per key with null deleting.""" merged = dict(base) for key, value in overlay.items(): - if _SCHEMA[key].kind == "map": + if _SCHEMA[key].kind in ("map", "section"): combined: dict[str, Any] = dict(merged.get(key, {})) for name, entry in value.items(): if entry is None: @@ -383,10 +427,17 @@ def load_config(main_worktree: Path | None = None) -> Config: sources.append(source) for key, spec in _SCHEMA.items(): - if spec.entry is not None: + if spec.kind == "map" and spec.entry is not None: merged[key] = { name: _normalize_entry(spec.entry, value) for name, value in merged[key].items() } + make: dict[str, Any] = merged["make"] + merged["make"] = MakeSpec( + hidden=make.get("hidden", False), + hide_scripts=tuple(make.get("hide_scripts", ())), + show_scripts=tuple(make.get("show_scripts", ())), + exclusive_scripts=tuple(make.get("exclusive_scripts", ())), + ) _validate_references(merged["openers"], merged["wrappers"], merged["scripts"]) return Config(**merged, sources=sources) diff --git a/src/workforest/hooks.py b/src/workforest/hooks.py index 10cdf99..d4f61de 100644 --- a/src/workforest/hooks.py +++ b/src/workforest/hooks.py @@ -41,7 +41,7 @@ from pathlib import Path from typing import IO -from workforest import gitutil, jobs, output +from workforest import gitutil, jobs, makefile, output from workforest.config import Config, ScriptSpec from workforest.errors import ScriptKilledError, WorkforestError @@ -759,11 +759,17 @@ def _orphan_cleanup( def _resolve_script(config: Config, name: str) -> ScriptSpec: + """A `scripts` entry, or — for a `make:TARGET` name — the synthetic + entry that target runs as. A configured name always wins, so a + `scripts` key spelled `make:...` is never shadowed.""" spec = config.scripts.get(name) - if spec is None: - available = ", ".join(sorted(config.scripts)) or "none defined" - raise WorkforestError(f"no script named {name!r} (available: {available})") - return spec + if spec is not None: + return spec + target = makefile.target_of(name) + if target is not None: + return makefile.spec_for(config, target) + available = ", ".join(sorted(config.scripts)) or "none defined" + raise WorkforestError(f"no script named {name!r} (available: {available})") def _stop_timeout(config: Config, spec: ScriptSpec) -> float: diff --git a/src/workforest/makefile.py b/src/workforest/makefile.py new file mode 100644 index 0000000..24d0908 --- /dev/null +++ b/src/workforest/makefile.py @@ -0,0 +1,136 @@ +"""Makefile targets as scripts. + +Where `make` is installed and the worktree root holds a makefile, every +target it defines is runnable as `wf make TARGET` — the same machinery +`wf run` uses, so a target is recorded, stopped, and counted like any +other script. Its script name is the target prefixed with `make:`, which +is what job records, `wf stop --make`, and `wf list --json` show; nothing +in `scripts` is shadowed, and the two namespaces never collide. + +Targets are discovered by reading the makefile (and what it includes) +rather than by asking make: listing what a project can run must not +evaluate `$(shell ...)`. The parse is therefore approximate by design — +it finds the plain rules a human would call and leaves generated ones +alone — and it never gates `wf make`, which passes the target to make +untouched. +""" + +import re +import shlex +import shutil +from pathlib import Path + +from workforest.config import Config, ScriptSpec +from workforest.errors import WorkforestError + +PREFIX = "make:" +# The names GNU make looks for, in its own order. +MAKEFILE_NAMES = ("GNUmakefile", "makefile", "Makefile") +_INCLUDE = re.compile(r"^[-s]?include\s+(.*)$") +_MAX_INCLUDE_DEPTH = 8 + + +def script_name(target: str) -> str: + return f"{PREFIX}{target}" + + +def target_of(name: str) -> str | None: + """The target a `make:` script name addresses, else None.""" + return name[len(PREFIX) :] if name.startswith(PREFIX) and len(name) > len(PREFIX) else None + + +def makefile_path(root: Path) -> Path | None: + for name in MAKEFILE_NAMES: + candidate = root / name + if candidate.is_file(): + return candidate + return None + + +def available(root: Path) -> bool: + """Whether `wf make` has anything to offer here.""" + return shutil.which("make") is not None and makefile_path(root) is not None + + +def require(root: Path) -> None: + if shutil.which("make") is None: + raise WorkforestError("make is not installed") + if makefile_path(root) is None: + names = ", ".join(MAKEFILE_NAMES) + raise WorkforestError(f"no makefile in {root} (looked for {names})") + + +def rule_targets(line: str) -> list[str]: + """The targets one makefile line defines, ignoring everything that is + not a plain rule: recipes and comments, variable assignments (`:=` + and friends), pattern rules, `$(...)`-built names, and make's own + dot-targets (`.PHONY`).""" + if not line or line[0] in " \t#": + return [] + head, separator, rest = line.partition(":") + if not separator or "=" in head or "#" in head: + return [] + if rest.lstrip(":").startswith("="): # `X := v`, `X ::= v`, `X :::= v` + return [] + return [ + target + for target in head.split() + if not target.startswith(".") and "%" not in target and "$" not in target + ] + + +def _included(line: str, directory: Path) -> list[Path]: + """The files an `include`/`-include`/`sinclude` line pulls in; a path + built from variables is not one we can resolve, so it is skipped.""" + match = _INCLUDE.match(line) + if match is None: + return [] + return [directory / word for word in match.group(1).split() if "$" not in word] + + +def _collect(path: Path, found: list[str], seen: set[Path], depth: int) -> None: + resolved = path.resolve() + if depth > _MAX_INCLUDE_DEPTH or resolved in seen: + return + seen.add(resolved) + try: + text = path.read_text(errors="replace") + except OSError: # an `-include` of something not generated yet, or unreadable + return + for line in text.splitlines(): + found.extend(target for target in rule_targets(line) if target not in found) + for included in _included(line, path.parent): + _collect(included, found, seen, depth + 1) + + +def targets(root: Path) -> list[str]: + """Every target the worktree's makefile defines, in file order (make's + default goal first); empty where there is no makefile.""" + path = makefile_path(root) + if path is None: + return [] + found: list[str] = [] + _collect(path, found, set(), 0) + return found + + +def visible_targets(config: Config, root: Path) -> list[str]: + """The targets offered by name — completions, editor lists. `wf make` + itself is not restricted by these: hiding a target keeps it out of the + way, it does not take it away.""" + spec = config.make + if spec.hidden or not available(root): + return [] + if spec.show_scripts: + allowed = frozenset(spec.show_scripts) + return [target for target in targets(root) if target in allowed] + left_out = frozenset(spec.hide_scripts) + return [target for target in targets(root) if target not in left_out] + + +def spec_for(config: Config, target: str) -> ScriptSpec: + """The synthetic `scripts` entry a target runs as.""" + return ScriptSpec( + command=f"make {shlex.quote(target)}", + exclusive=target in config.make.exclusive_scripts, + ) diff --git a/src/workforest/shell/completion.bash b/src/workforest/shell/completion.bash index cfab6bc..34fdc65 100644 --- a/src/workforest/shell/completion.bash +++ b/src/workforest/shell/completion.bash @@ -14,7 +14,10 @@ _workforest_complete() { case "$cmd" in create) topic=branches ;; open|delete|checkout) topic=worktrees ;; - run|stop) topic=scripts ;; + make) topic=make ;; + run) topic=scripts ;; + # `wf stop --make TARGET` names a makefile target, not a script. + stop) topic=scripts; case " ${COMP_WORDS[*]} " in *" --make "*) topic=make ;; esac ;; claude) topic=claude-sessions ;; tui|list|init|config|shell-init) topic=none ;; *) topic=worktrees ;; diff --git a/src/workforest/shell/completion.zsh b/src/workforest/shell/completion.zsh index 0d0b133..d99a102 100644 --- a/src/workforest/shell/completion.zsh +++ b/src/workforest/shell/completion.zsh @@ -13,7 +13,10 @@ _workforest_complete() { case "$cmd" in create) topic=branches ;; open|delete|checkout) topic=worktrees ;; - run|stop) topic=scripts ;; + 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 ;; diff --git a/src/workforest/templates/project.yaml b/src/workforest/templates/project.yaml index ec75208..d7f6935 100644 --- a/src/workforest/templates/project.yaml +++ b/src/workforest/templates/project.yaml @@ -19,3 +19,9 @@ # dev: # command: npm run dev # exclusive: true + +# Which makefile targets `wf make` offers by name (completions, editor +# plugins) and which of them run one at a time +# make: +# hide_scripts: [install] +# exclusive_scripts: [dev] diff --git a/tests/test_makefile.py b/tests/test_makefile.py new file mode 100644 index 0000000..6cf38b2 --- /dev/null +++ b/tests/test_makefile.py @@ -0,0 +1,250 @@ +"""Makefile targets as scripts: what the parser finds, what the `make` +config offers, and how `wf make` / `wf stop --make` behave.""" + +import shutil +from collections.abc import Callable +from pathlib import Path + +import pytest + +from workforest import makefile +from workforest.config import Config, MakeSpec, load_config +from workforest.errors import WorkforestError + +from .conftest import CliResult, Repo + +Run = Callable[..., CliResult] + +MAKEFILE = """\ +# Dev targets. +-include Makefile.local + +VERSION := 1.0 +PREFIX ?= /usr/local +FLAGS ::= -g + +.PHONY: build test + +build: deps +\t@echo building +test: FLAGS = -O0 +test: +\t@echo testing $(VERSION) +lint check: +\t@echo both +%.o: %.c +\t@echo pattern +$(PREFIX)/bin/tool: +\t@echo generated +""" + + +def write_makefile(root: Path, text: str = MAKEFILE, name: str = "Makefile") -> Path: + path = root / name + path.write_text(text) + return path + + +class TestParsing: + def test_finds_plain_rules_in_file_order(self, tmp_path: Path) -> None: + """Rules only: a prerequisite (`build: deps`) is not a definition.""" + write_makefile(tmp_path) + assert makefile.targets(tmp_path) == ["build", "test", "lint", "check"] + + @pytest.mark.parametrize( + "line", + [ + "\t@echo recipe", + "# comment: not a rule", + "VERSION := 1.0", + "VERSION ::= 1.0", + "VERSION :::= 1.0", + "PREFIX ?= /usr/local", + "", + "no colon here", + ".PHONY: build", + "%.o: %.c", + "$(PREFIX)/bin/tool:", + ], + ) + def test_lines_that_define_no_target(self, line: str) -> None: + assert makefile.rule_targets(line) == [] + + def test_double_colon_and_order_only_rules(self) -> None: + assert makefile.rule_targets("clean:: extra") == ["clean"] + assert makefile.rule_targets("app: | build-dir") == ["app"] + + def test_follows_includes_relative_to_the_makefile(self, tmp_path: Path) -> None: + """In make's own order: an included file is read where it is named.""" + write_makefile(tmp_path, "include extra.mk\nlocal:\n\t@echo local\n") + (tmp_path / "extra.mk").write_text("included:\n\t@echo included\n") + assert makefile.targets(tmp_path) == ["included", "local"] + + def test_missing_include_and_variable_paths_are_skipped(self, tmp_path: Path) -> None: + write_makefile(tmp_path, "-include gone.mk $(GENERATED)\nlocal:\n\t@echo local\n") + assert makefile.targets(tmp_path) == ["local"] + + def test_include_cycles_end(self, tmp_path: Path) -> None: + write_makefile(tmp_path, "include a.mk\nroot:\n\t@echo root\n") + (tmp_path / "a.mk").write_text("include Makefile\na:\n\t@echo a\n") + assert makefile.targets(tmp_path) == ["a", "root"] + + def test_no_makefile_no_targets(self, tmp_path: Path) -> None: + assert makefile.targets(tmp_path) == [] + assert makefile.makefile_path(tmp_path) is None + assert not makefile.available(tmp_path) + + def test_gnu_makefile_wins_over_makefile(self, tmp_path: Path) -> None: + write_makefile(tmp_path, "fallback:\n\t@echo fallback\n") + write_makefile(tmp_path, "preferred:\n\t@echo preferred\n", name="GNUmakefile") + assert makefile.targets(tmp_path) == ["preferred"] + + +class TestNames: + def test_round_trip(self) -> None: + assert makefile.script_name("check") == "make:check" + assert makefile.target_of("make:check") == "check" + + @pytest.mark.parametrize("name", ["check", "make:", "makecheck", ""]) + def test_other_names_are_not_targets(self, name: str) -> None: + assert makefile.target_of(name) is None + + +class TestVisibility: + def config(self, **make: object) -> Config: + return Config(make=MakeSpec(**make)) # type: ignore[arg-type] + + @pytest.fixture(autouse=True) + def _makefile(self, tmp_path: Path) -> None: + write_makefile(tmp_path) + + def test_everything_by_default(self, tmp_path: Path) -> None: + targets = makefile.visible_targets(self.config(), tmp_path) + assert targets == ["build", "test", "lint", "check"] + + def test_hide(self, tmp_path: Path) -> None: + assert makefile.visible_targets(self.config(hidden=True), tmp_path) == [] + + def test_hide_scripts(self, tmp_path: Path) -> None: + config = self.config(hide_scripts=("build", "lint")) + assert makefile.visible_targets(config, tmp_path) == ["test", "check"] + + def test_show_scripts_wins_over_hide_scripts(self, tmp_path: Path) -> None: + config = self.config(show_scripts=("check", "test"), hide_scripts=("check",)) + assert makefile.visible_targets(config, tmp_path) == ["test", "check"] + + def test_nothing_where_there_is_no_makefile(self, tmp_path: Path) -> None: + (tmp_path / "Makefile").unlink() + assert makefile.visible_targets(self.config(), tmp_path) == [] + + +class TestSpec: + def test_command_quotes_the_target(self) -> None: + spec = makefile.spec_for(Config(), "a target") + assert spec.command == "make 'a target'" + assert not spec.exclusive + + def test_exclusive_scripts(self) -> None: + config = Config(make=MakeSpec(exclusive_scripts=("dev",))) + assert makefile.spec_for(config, "dev").exclusive + assert not makefile.spec_for(config, "check").exclusive + + +class TestRequire: + def test_no_makefile(self, tmp_path: Path) -> None: + with pytest.raises(WorkforestError, match="no makefile"): + makefile.require(tmp_path) + + def test_no_make(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(makefile.shutil, "which", lambda _: None) + with pytest.raises(WorkforestError, match="make is not installed"): + makefile.require(tmp_path) + + +@pytest.mark.skipif(shutil.which("make") is None, reason="make not installed") +class TestCommand: + def test_runs_the_target_at_the_worktree_root(self, repo: Repo, run_cli: Run) -> None: + write_makefile(repo.path, "hello:\n\t@echo hello-from-make\n") + result = run_cli("make", "hello", cwd=repo.path) + assert result.code == 0 + assert "hello-from-make" in result.err + assert "running 'make:hello'" in result.err + + def test_extra_args_are_appended(self, repo: Repo, run_cli: Run) -> None: + write_makefile(repo.path, "show:\n\t@echo goal=$(GOAL)\n") + result = run_cli("make", "show", "GOAL=yes", cwd=repo.path) + assert result.code == 0 + assert "goal=yes" in result.err + + def test_a_failing_target_fails(self, repo: Repo, run_cli: Run) -> None: + """Reported exactly as a failing `wf run` command is.""" + write_makefile(repo.path, "boom:\n\t@exit 3\n") + result = run_cli("make", "boom", cwd=repo.path) + assert result.code == 1 + assert "script 'make:boom' failed with exit code 2" in result.err + + def test_an_unknown_target_is_make_s_to_reject(self, repo: Repo, run_cli: Run) -> None: + write_makefile(repo.path, "hello:\n\t@echo hi\n") + result = run_cli("make", "nope", cwd=repo.path) + assert result.code != 0 + + def test_a_hidden_target_still_runs(self, repo: Repo, run_cli: Run) -> None: + write_makefile(repo.path, "hello:\n\t@echo hi\n") + repo.write_project_config("make:\n hidden: true\n") + assert run_cli("make", "hello", cwd=repo.path).code == 0 + assert run_cli("--complete", "make", cwd=repo.path).out == "" + + def test_without_a_makefile_it_says_so(self, repo: Repo, run_cli: Run) -> None: + result = run_cli("make", "hello", cwd=repo.path) + assert result.code == 1 + assert "no makefile" in result.err + + def test_stop_make_needs_a_running_target(self, repo: Repo, run_cli: Run) -> None: + write_makefile(repo.path, "hello:\n\t@echo hi\n") + result = run_cli("stop", "--make", "hello", cwd=repo.path) + assert result.code == 1 + assert "'make:hello' is not running" in result.err + + def test_completion_lists_the_visible_targets(self, repo: Repo, run_cli: Run) -> None: + write_makefile(repo.path, "build:\n\t@echo b\ntest:\n\t@echo t\n") + repo.write_project_config("make:\n hide_scripts: [test]\n") + assert run_cli("--complete", "make", cwd=repo.path).out == "build\n" + + +class TestConfigSection: + def test_defaults(self, tmp_path: Path) -> None: + assert load_config(None).make == MakeSpec() + + def test_layers_merge_per_key(self, repo: Repo) -> None: + repo.write_project_config("make:\n hidden: true\n hide_scripts: [a]\n") + repo.write_project_config("make:\n hidden: false\n", subdir=".vscode") + config = load_config(repo.path) + assert config.make == MakeSpec(hidden=False, hide_scripts=("a",)) + + def test_null_restores_the_default(self, repo: Repo) -> None: + repo.write_project_config("make:\n hide_scripts: [a]\n") + repo.write_project_config("make:\n hide_scripts: null\n", subdir=".vscode") + assert load_config(repo.path).make.hide_scripts == () + + def test_it_shows_up_in_the_dump(self, repo: Repo, run_cli: Run) -> None: + repo.write_project_config("make:\n exclusive_scripts: [dev]\n") + result = run_cli("config", cwd=repo.path) + assert "exclusive_scripts:\n - dev" in result.out + + @pytest.mark.parametrize( + ("text", "message"), + [ + ("make: []\n", "'make' must be a mapping"), + ("make:\n nope: true\n", "make.nope: unknown key"), + ("make:\n hidden: yes please\n", "make.hidden must be true or false"), + ("make:\n hide_scripts: check\n", "make.hide_scripts must be a list of strings"), + ("make:\n show_scripts: [1]\n", "make.show_scripts must be a list of strings"), + ], + ) + def test_rejects_a_malformed_section( + self, repo: Repo, run_cli: Run, text: str, message: str + ) -> None: + repo.write_project_config(text) + result = run_cli("config", cwd=repo.path) + assert result.code == 4 + assert message in result.err