diff --git a/README.md b/README.md index e986228f..0392ee8a 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,7 @@ than the filesystem has actually done. | `dl restart` | Stop and start (no rebuild) | | `dl recreate` | Recreate container | | `dl reset` | Clean slate (remove all, recreate) | +| `dl dotfiles` | Refresh dotfiles in the running workspace (`chezmoi update`) | | `dl -- ` | Run shell command in workspace (with a terminal, when `dl` has one) | ## Options diff --git a/devlaunch/completions/dl.bash b/devlaunch/completions/dl.bash index b844eb13..056444c8 100644 --- a/devlaunch/completions/dl.bash +++ b/devlaunch/completions/dl.bash @@ -61,7 +61,7 @@ _dl_completion() { fi # Workspace subcommands - local ws_cmds="up stop rm code restart recreate reset --" + local ws_cmds="up stop rm code restart recreate reset dotfiles --" # Options that take a value; a variant name or a path follows them. local value_opts="--devcontainer" diff --git a/devlaunch/dl.py b/devlaunch/dl.py index 991cd159..428f0e79 100644 --- a/devlaunch/dl.py +++ b/devlaunch/dl.py @@ -2135,6 +2135,39 @@ def attach_workspace(workspace_id: str, shell_command: Optional[str] = None) -> return workspace_ssh(workspace_id, shell_command) +def dotfiles_update(workspace_id: str) -> int: + """Refresh dotfiles inside a running workspace. + + Runs chezmoi update + pixi global sync. Falls back to running install.sh + if chezmoi is not available (e.g. workspace predates dotfiles setup). + """ + ctx = get_context_options() + dotfiles_url = ctx.get("DOTFILES_URL", "") + + if dotfiles_url: + fallback = ( + f'echo "chezmoi not found, running full install..." && ' + f"DOTFILES_DIR=$(mktemp -d) && " + f'git clone {shlex.quote(dotfiles_url)} "$DOTFILES_DIR" && ' + f'cd "$DOTFILES_DIR" && bash install.sh && ' + f'rm -rf "$DOTFILES_DIR" && ' + f'echo "Dotfiles installed successfully"' + ) + else: + fallback = 'echo "chezmoi not found and no DOTFILES_URL configured" && exit 1' + + update_cmd = ( + "if command -v chezmoi >/dev/null 2>&1; then " + 'echo "Updating dotfiles..." && ' + "chezmoi update --force && " + 'echo "Syncing pixi global packages..." && ' + "pixi global sync && " + 'echo "Dotfiles updated successfully"; ' + f"else {fallback}; fi" + ) + return workspace_ssh(workspace_id, command=update_cmd) + + def workspace_stop(workspace: str) -> int: """Stop a workspace.""" result = run_devpod(["stop", workspace]) @@ -2229,6 +2262,7 @@ def print_help(): dl restart Stop and start (no rebuild) dl recreate Recreate container dl reset Clean slate (remove all, recreate) + dl dotfiles Refresh dotfiles (chezmoi update) dl -- Run shell command in workspace Global commands: @@ -2751,6 +2785,16 @@ def _run_cli(argv: Optional[List[str]] = None) -> int: update_cache_background(force=True) return ret + if subcommand == "dotfiles": + # Ensure workspace is running before refreshing dotfiles + state = get_workspace_state(workspace_id) + if state != "Running": + logging.info(f"Starting workspace {workspace_id}...") + result = workspace_up(workspace_spec, workspace_id=custom_id) + if result.returncode != 0: + return result.returncode + return dotfiles_update(workspace_id) + # Check for shell command (after --) shell_command = None if subcommand == "--" and len(args) > 2: diff --git a/test/test_dl.py b/test/test_dl.py index 6626ff25..7513cf4e 100644 --- a/test/test_dl.py +++ b/test/test_dl.py @@ -46,6 +46,7 @@ workspace_stop, workspace_delete, workspace_ssh, + dotfiles_update, run_devpod, extract_devcontainer_flag, workspace_up, @@ -3412,3 +3413,102 @@ def test_a_rejected_workspace_spec_spawns_nothing(self, mock_popen, _mock_state, with patch.object(sys, "argv", ["dl", "nonexistent"]): assert main() == 1 mock_popen.assert_not_called() + + +class TestDotfilesUpdate: + """Tests for dotfiles_update().""" + + @patch("devlaunch.dl.workspace_ssh") + @patch("devlaunch.dl.get_context_options") + def test_runs_chezmoi_update_command(self, mock_ctx, mock_ssh): + """dotfiles_update runs chezmoi update + pixi global sync via SSH.""" + mock_ctx.return_value = {"DOTFILES_URL": "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/user/dots"} + mock_ssh.return_value = 0 + result = dotfiles_update("myws") + assert result == 0 + mock_ssh.assert_called_once() + cmd = mock_ssh.call_args[1]["command"] + assert "chezmoi update --force" in cmd + assert "pixi global sync" in cmd + + @patch("devlaunch.dl.workspace_ssh") + @patch("devlaunch.dl.get_context_options") + def test_fallback_includes_dotfiles_url(self, mock_ctx, mock_ssh): + """Fallback clone uses DOTFILES_URL from context.""" + mock_ctx.return_value = {"DOTFILES_URL": "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/user/dots"} + mock_ssh.return_value = 0 + dotfiles_update("myws") + cmd = mock_ssh.call_args[1]["command"] + assert "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/user/dots" in cmd + + @patch("devlaunch.dl.workspace_ssh") + @patch("devlaunch.dl.get_context_options") + def test_fallback_quotes_the_url(self, mock_ctx, mock_ssh): + """The URL is interpolated into a shell command, so it is quoted. + + DOTFILES_URL comes from local devpod config rather than anywhere + hostile, but a URL containing a space would otherwise split into two + `git clone` arguments and fail with a message about the wrong thing. + """ + mock_ctx.return_value = {"DOTFILES_URL": "https://example.com/a repo"} + mock_ssh.return_value = 0 + dotfiles_update("myws") + cmd = mock_ssh.call_args[1]["command"] + assert "git clone 'https://example.com/a repo'" in cmd + + @patch("devlaunch.dl.workspace_ssh") + @patch("devlaunch.dl.get_context_options") + def test_no_dotfiles_url_fallback_exits(self, mock_ctx, mock_ssh): + """Without DOTFILES_URL, fallback reports error.""" + mock_ctx.return_value = {} + mock_ssh.return_value = 0 + dotfiles_update("myws") + cmd = mock_ssh.call_args[1]["command"] + assert "no DOTFILES_URL configured" in cmd + + +class TestMainDotfilesSubcommand: + """Tests for dotfiles subcommand in main().""" + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.get_workspace_state") + @patch("devlaunch.dl.dotfiles_update") + def test_dotfiles_running_workspace(self, mock_dotfiles, mock_state, mock_ids): + """dotfiles subcommand on running workspace skips workspace_up.""" + mock_ids.return_value = ["myws"] + mock_state.return_value = "Running" + mock_dotfiles.return_value = 0 + with patch.object(sys, "argv", ["dl", "myws", "dotfiles"]): + result = main() + assert result == 0 + mock_dotfiles.assert_called_once_with("myws") + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.get_workspace_state") + @patch("devlaunch.dl.workspace_up") + @patch("devlaunch.dl.dotfiles_update") + def test_dotfiles_stopped_workspace_starts_first( + self, mock_dotfiles, mock_up, mock_state, mock_ids + ): + """dotfiles subcommand on stopped workspace starts it first.""" + mock_ids.return_value = ["myws"] + mock_state.return_value = "Stopped" + mock_up.return_value = MagicMock(returncode=0) + mock_dotfiles.return_value = 0 + with patch.object(sys, "argv", ["dl", "myws", "dotfiles"]): + result = main() + assert result == 0 + mock_up.assert_called_once() + mock_dotfiles.assert_called_once_with("myws") + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.get_workspace_state") + @patch("devlaunch.dl.workspace_up") + def test_dotfiles_start_failure_propagates(self, mock_up, mock_state, mock_ids): + """dotfiles subcommand propagates workspace_up failure.""" + mock_ids.return_value = ["myws"] + mock_state.return_value = "Stopped" + mock_up.return_value = MagicMock(returncode=3) + with patch.object(sys, "argv", ["dl", "myws", "dotfiles"]): + result = main() + assert result == 3