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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ than the filesystem has actually done.
| `dl <user/repo> restart` | Stop and start (no rebuild) |
| `dl <user/repo> recreate` | Recreate container |
| `dl <user/repo> reset` | Clean slate (remove all, recreate) |
| `dl <user/repo> dotfiles` | Refresh dotfiles in the running workspace (`chezmoi update`) |
| `dl <user/repo> -- <command>` | Run shell command in workspace (with a terminal, when `dl` has one) |

## Options
Expand Down
2 changes: 1 addition & 1 deletion devlaunch/completions/dl.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
44 changes: 44 additions & 0 deletions devlaunch/dl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider restructuring the dotfiles update command into composable parts and extracting the repeated update-and-warn logic into a helper to simplify and centralize behavior.

You can reduce complexity without changing behavior by:

  1. Structuring the shell command construction
  2. Deduplicating the “update + warn” pattern

1. Make dotfiles_update easier to read/extend

Instead of one long interpolated string, build the command from small, named fragments and then join them. This keeps all behavior but lowers cognitive load:

def dotfiles_update(workspace_id: str) -> int:
    """Refresh dotfiles inside a running workspace."""
    ctx = get_context_options()
    dotfiles_url = ctx.get("DOTFILES_URL", "")

    if dotfiles_url:
        fallback_parts = [
            'echo "chezmoi not found, running full install..."',
            'DOTFILES_DIR=$(mktemp -d)',
            f"git clone {dotfiles_url} \"$DOTFILES_DIR\"",
            'cd "$DOTFILES_DIR"',
            "bash install.sh",
            'rm -rf "$DOTFILES_DIR"',
            'echo "Dotfiles installed successfully"',
        ]
    else:
        fallback_parts = [
            'echo "chezmoi not found and no DOTFILES_URL configured"',
            "exit 1",
        ]

    chezmoi_parts = [
        'echo "Updating dotfiles..."',
        "chezmoi update --force",
        'echo "Syncing pixi global packages..."',
        "pixi global sync",
        'echo "Dotfiles updated successfully"',
    ]

    fallback_cmd = " && ".join(fallback_parts)
    chezmoi_cmd = " && ".join(chezmoi_parts)

    update_cmd = (
        "if command -v chezmoi >/dev/null 2>&1; then "
        f"{chezmoi_cmd}; "
        f"else {fallback_cmd}; "
        "fi"
    )

    return workspace_ssh(workspace_id, command=update_cmd)

Behavior is identical, but it’s now obvious where to add/remove steps.

2. Deduplicate “update dotfiles if possible” in main()

The “call dotfiles_update, check return, log warning, continue” pattern appears twice. A tiny helper keeps this in one place:

def refresh_dotfiles_if_possible(workspace_id: str) -> None:
    ret = dotfiles_update(workspace_id)
    if ret != 0:
        logging.warning("Dotfiles update failed, continuing anyway...")

Then in main():

# Fast-attach: skip workspace_up() if workspace is already running
if custom_id is None and get_workspace_state(workspace_id) == "Running":
    logging.info(f"Workspace {workspace_id} is already running, attaching...")
    setup_hostname(workspace_id)
    refresh_dotfiles_if_possible(workspace_id)
    ret = workspace_ssh(
        workspace_id,
        shell_command,
        workdir=get_container_workdir(workspace_id),
    )
    update_cache_background()
    return ret

# ...

# After ensuring workspace is up
setup_hostname(workspace_id)
refresh_dotfiles_if_possible(workspace_id)

# Attach to workspace
ret = workspace_ssh(
    workspace_id,
    shell_command,
    workdir=get_container_workdir(workspace_id),
)

This keeps all functionality intact while addressing the complexity concern and making future changes to the dotfiles refresh behavior centralized.

"""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])
Expand Down Expand Up @@ -2229,6 +2262,7 @@ def print_help():
dl <user/repo> restart Stop and start (no rebuild)
dl <user/repo> recreate Recreate container
dl <user/repo> reset Clean slate (remove all, recreate)
dl <user/repo> dotfiles Refresh dotfiles (chezmoi update)
dl <user/repo> -- <command> Run shell command in workspace

Global commands:
Expand Down Expand Up @@ -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:
Expand Down
100 changes: 100 additions & 0 deletions test/test_dl.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
workspace_stop,
workspace_delete,
workspace_ssh,
dotfiles_update,
run_devpod,
extract_devcontainer_flag,
workspace_up,
Expand Down Expand 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