From 123f72086add7d3dbb47d7dd88f1cbc06b25a0ff Mon Sep 17 00:00:00 2001 From: nasser1941 <9349840+nasser1941@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:11:45 +0200 Subject: [PATCH 1/2] Add lcode update: update lcode, and Ollama in Docker lcode updates itself the way it was installed: uv tool, pipx, Homebrew, pip, or git pull for a checkout (reinstalling the editable environment when the dependencies changed). Ollama in a Docker container is updated to the newest release on Docker Hub (looked up there, since a local latest tag can be months old; release candidates skipped; ROCm stays ROCm), pulled by its version tag so docker ps shows the version. A Compose-managed container gets the new tag in its compose file and docker compose up -d for its service, with the file put back if that fails. Any other container is recreated with the same name, ports, volumes, environment, GPUs, network and restart policy, and the old one comes back if the new one doesn't answer with the new version. Ollama outside Docker gets the command that updates it. --check only shows versions; -y doesn't ask. Co-Authored-By: Claude Opus 5.5 --- docs/installation.md | 31 ++- docs/usage.md | 1 + pyproject.toml | 1 + src/lcode/cli.py | 18 +- src/lcode/update.py | 557 +++++++++++++++++++++++++++++++++++++++++++ tests/test_update.py | 303 +++++++++++++++++++++++ 6 files changed, 907 insertions(+), 4 deletions(-) create mode 100644 src/lcode/update.py create mode 100644 tests/test_update.py diff --git a/docs/installation.md b/docs/installation.md index 7661da1..e3b7393 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -161,11 +161,36 @@ lmstudio` (or `llama.cpp`, `vllm`, `mlx`). See [Other model servers](servers.md) Use [WSL2](https://learn.microsoft.com/windows/wsl/install) with Ubuntu and follow the Ubuntu steps. NVIDIA GPUs work inside WSL2 with the regular Windows driver. -## Upgrade and uninstall +## Updating ```bash -uv tool upgrade lcode-cli # upgrade (pipx: pipx upgrade lcode-cli; Homebrew: brew upgrade lcode) -uv tool uninstall lcode-cli # remove lcode (Homebrew: brew uninstall lcode) +lcode update # update lcode, and Ollama if it runs in Docker +lcode update --check # only show which versions are out +``` + +- **lcode** updates the way you installed it: `uv tool upgrade`, `pipx upgrade`, `brew upgrade` + or `pip`. If it runs from a git checkout, `git pull` (on `main`, without local changes). +- **Ollama in a Docker container** is updated to the newest release on Docker Hub. lcode looks + that up instead of trusting a local `latest` tag, which can be months old, and skips release + candidates. It pulls the version's own tag, such as `ollama/ollama:0.35.1`, so `docker ps` shows + which version runs. A ROCm image stays ROCm. + - **A container from Docker Compose:** lcode changes the `image:` line in its compose file and + runs `docker compose up -d` for that service, so Compose keeps managing it. + - **Any other container** is recreated with the same name, ports, volumes, environment, GPUs, + network and restart policy. The old one is kept until the new one answers with the new + version, and is put back if it doesn't. + - Your models stay: they're in the container's volume. +- **Ollama installed without Docker** gets the command that updates it: on Linux, + `curl -fsSL https://ollama.com/install.sh | sh`; on macOS, `brew upgrade ollama` or the app + itself. + +lcode shows what it will do and asks first. `lcode update lcode` or `lcode update ollama` updates +only one; `-y` doesn't ask. + +## Uninstall + +```bash +uv tool uninstall lcode-cli # remove lcode (pipx: pipx uninstall lcode-cli; Homebrew: brew uninstall lcode) rm -rf ~/.config/lcode ~/.local/state/lcode # remove settings and saved sessions ollama rm lcode-qwen3.6-35b qwen3.6:35b-a3b-coding # remove downloaded models ``` diff --git a/docs/usage.md b/docs/usage.md index 76770a5..a99db2f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -11,6 +11,7 @@ lcode config [set|unset] # show or change settings lcode bench [models] # score models on small coding tasks on this machine lcode mcp [catalog|add|list] # connect MCP servers: Jira, GitHub, AWS, databases… (see MCP servers) lcode acp # run inside an editor over the Agent Client Protocol (see Editors) +lcode update [--check] # update lcode, and Ollama when it runs in Docker (see Installation) ``` | Option | Meaning | diff --git a/pyproject.toml b/pyproject.toml index 72b007f..36abd97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,7 @@ ignore = [ "src/lcode/gitflow.py" = ["E501"] "src/lcode/action.py" = ["E501"] "src/lcode/acp.py" = ["E501"] +"src/lcode/update.py" = ["E501"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/lcode/cli.py b/src/lcode/cli.py index 6e9d581..629e94e 100644 --- a/src/lcode/cli.py +++ b/src/lcode/cli.py @@ -678,7 +678,7 @@ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="lcode", description="A local-first terminal coding agent powered by open-weight models via Ollama.", - epilog="Subcommands: lcode setup | models | doctor | config | bench | mcp | index " + epilog="Subcommands: lcode setup | models | doctor | config | bench | mcp | index | update " "(lcode --help). " "Docs: https://nasser1941.github.io/lcode/", ) @@ -774,6 +774,15 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]: from lcode.mcp.commands import build_parser as mcp_parser subs["mcp"] = mcp_parser() + p = argparse.ArgumentParser( + prog="lcode update", + description="Update lcode, and Ollama when it runs in Docker (to the newest version on Docker Hub, pinned " + "to its version tag).", + ) + p.add_argument("what", nargs="?", choices=["lcode", "ollama"], help="only this one (default: both)") + p.add_argument("--check", action="store_true", help="only show which versions are out") + p.add_argument("-y", "--yes", action="store_true", help="don't ask before updating") + subs["update"] = p subs["acp"] = argparse.ArgumentParser( prog="lcode acp", description="Run as the agent inside an editor, over the Agent Client Protocol on stdin and stdout " @@ -787,6 +796,12 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]: return subs +def cmd_update(args) -> None: + from lcode import update + + sys.exit(update.command(console, args.what or "", check=args.check, yes=args.yes)) + + def cmd_acp(args) -> None: from lcode import acp @@ -863,6 +878,7 @@ def cmd_mcp(args) -> None: "index": cmd_index, "action": cmd_action, "acp": cmd_acp, + "update": cmd_update, } diff --git a/src/lcode/update.py b/src/lcode/update.py new file mode 100644 index 0000000..6025103 --- /dev/null +++ b/src/lcode/update.py @@ -0,0 +1,557 @@ +"""`lcode update`: update lcode, and Ollama when it runs in Docker. + +lcode updates itself the way it was installed: uv tool, pipx, Homebrew, pip, or `git pull` for a +checkout (an editable install). + +For Ollama in Docker, the newest version is looked up on Docker Hub, never taken from a local +`latest` tag (which can be months old), and pulled by its version tag (`ollama/ollama:0.35.1`), so +`docker ps` shows which version runs. Release candidates are skipped, and a ROCm image stays ROCm. +- A container from Docker Compose gets the new tag in its compose file and is recreated with + `docker compose up`, so Compose keeps managing it. +- Any other container is recreated with the same name, ports, volumes, environment, GPUs, network + and restart policy. The old one is kept until the new one answers with the new version, and is + put back if it doesn't. +Ollama installed without Docker gets the command to update it, which lcode doesn't run itself. +""" + +from __future__ import annotations + +import importlib.metadata +import json +import re +import shutil +import subprocess +import sys +import time +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from urllib.parse import urlsplit + +import requests + +from lcode import __version__ + +IMAGE = "ollama/ollama" +HUB_TAGS = "https://hub.docker.com/v2/namespaces/ollama/repositories/ollama/tags" +PYPI = "https://pypi.org/pypi/lcode-cli/json" +SEMVER = re.compile(r"\d+\.\d+\.\d+") +START_TIMEOUT = 120 # seconds for a recreated container to answer +NATIVE = { + "linux": "curl -fsSL https://ollama.com/install.sh | sh", + "darwin": "brew upgrade ollama (or let the Ollama app update itself)", +} + + +class UpdateError(Exception): + pass + + +def version_key(version: str) -> tuple[int, ...]: + return tuple(int(p) for p in re.findall(r"\d+", version)[:3]) + + +def run(argv: list[str], cwd: Path | None = None, timeout: float = 120) -> subprocess.CompletedProcess: + """A command whose output lcode reads (and tests replace).""" + try: + return subprocess.run(argv, cwd=cwd, capture_output=True, text=True, timeout=timeout) + except (OSError, subprocess.SubprocessError) as e: + raise UpdateError(f"{argv[0]} failed: {e}") from e + + +def stream(argv: list[str], cwd: Path | None = None) -> int: + """A command the user watches (pulls, installs); returns its exit code.""" + try: + return subprocess.call(argv, cwd=cwd) + except OSError as e: + raise UpdateError(f"{argv[0]} failed: {e}") from e + + +def get_json(url: str, **params) -> dict: + try: + r = requests.get(url, params=params or None, timeout=20) + r.raise_for_status() + return r.json() + except (requests.RequestException, ValueError) as e: + raise UpdateError(f"couldn't read {url.split('?')[0]}: {e}") from e + + +# ----------------------------------------------------------------------------- the newest versions + + +def latest_lcode() -> str: + return str(get_json(PYPI)["info"]["version"]) + + +def latest_ollama(flavor: str = "") -> str: + """The newest released Ollama image tag on Docker Hub: "0.35.1", or "0.35.1-rocm" for flavor "rocm".""" + names: list[str] = [] + url, params = HUB_TAGS, {"page_size": 100, "ordering": "last_updated"} + for _ in range(3): # the newest tags come first; three pages reach well past them + page = get_json(url, **params) + names += [str(t.get("name", "")) for t in page.get("results") or []] + url, params = page.get("next"), {} + if not url: + break + suffix = f"-{flavor}" if flavor else "" + versions = [ + n.removesuffix(suffix) for n in names if n.endswith(suffix) and SEMVER.fullmatch(n.removesuffix(suffix)) + ] + if not versions: + raise UpdateError(f"Docker Hub lists no released {IMAGE} versions{f' for {flavor}' if flavor else ''}") + return max(versions, key=version_key) + suffix + + +def image_flavor(image: str) -> str: + """The image variant to stay on: "rocm" for ollama/ollama:rocm or :0.35.1-rocm, else "".""" + tag = image.rsplit(":", 1)[1] if ":" in image.rsplit("/", 1)[-1] else "latest" + return "rocm" if tag == "rocm" or tag.endswith("-rocm") else "" + + +# ----------------------------------------------------------------------------- lcode itself + + +@dataclass +class Install: + kind: str # checkout, uv, pipx, brew, uvx, pip + command: list[str] | None = None # what updates it + path: Path | None = None # the checkout, for kind checkout + + def describe(self) -> str: + return { + "checkout": f"a git checkout at {self.path}", + "uv": "uv tool", + "pipx": "pipx", + "brew": "Homebrew", + "uvx": "uvx", + "pip": "pip", + }[self.kind] + + +def lcode_install(prefix: Path | None = None, direct_url: str | None = None) -> Install: + """How this lcode was installed.""" + prefix = (prefix or Path(sys.prefix)).resolve() + if direct_url is None: + try: + direct_url = importlib.metadata.distribution("lcode-cli").read_text("direct_url.json") or "" + except importlib.metadata.PackageNotFoundError: + direct_url = "" + try: + origin = json.loads(direct_url) if direct_url else {} + except ValueError: + origin = {} + if (origin.get("dir_info") or {}).get("editable") and str(origin.get("url", "")).startswith("file://"): + return Install("checkout", path=Path(urlsplit(origin["url"]).path)) + text = str(prefix) + if (prefix / "uv-receipt.toml").exists(): + return Install("uv", ["uv", "tool", "upgrade", "lcode-cli"]) + if "/Cellar/lcode/" in text: + return Install("brew", ["brew", "upgrade", "nasser1941/tap/lcode"]) + if "pipx" in prefix.parts: + return Install("pipx", ["pipx", "upgrade", "lcode-cli"]) + if "/uv/" in text and ("archive-v" in text or "/cache/" in text or "/.cache/" in text): + return Install("uvx") + return Install("pip", [sys.executable, "-m", "pip", "install", "--upgrade", "lcode-cli"]) + + +@dataclass +class CheckoutState: + branch: str + dirty: bool + behind: int + upstream: str + + +def checkout_state(path: Path) -> CheckoutState: + def git(*args: str) -> str: + r = run(["git", "-C", str(path), *args], timeout=120) + if r.returncode != 0: + raise UpdateError(f"git {args[0]} failed in {path}: {(r.stderr or r.stdout).strip()[:200]}") + return r.stdout.strip() + + branch = git("rev-parse", "--abbrev-ref", "HEAD") + dirty = bool(git("status", "--porcelain", "--untracked-files=no")) + upstream = run(["git", "-C", str(path), "rev-parse", "--abbrev-ref", "@{upstream}"]).stdout.strip() + behind = 0 + if upstream: + git("fetch", "-q") + behind = int(git("rev-list", "--count", "HEAD..@{upstream}") or 0) + return CheckoutState(branch, dirty, behind, upstream) + + +def update_checkout(path: Path, install_receipt: bool) -> str: + """git pull a checkout lcode runs from; reinstall its environment when its dependencies changed.""" + before = run(["git", "-C", str(path), "rev-parse", "HEAD"]).stdout.strip() + if stream(["git", "-C", str(path), "pull", "--ff-only"]) != 0: + raise UpdateError(f"git pull failed in {path}") + changed = run(["git", "-C", str(path), "diff", "--name-only", before, "HEAD"]).stdout.split() + reinstall = ["uv", "tool", "install", "--force", "--editable", str(path)] + if "pyproject.toml" in changed and install_receipt and stream(reinstall) != 0: + raise UpdateError("reinstalling lcode's environment failed: run uv tool install --force --editable .") + count = run(["git", "-C", str(path), "rev-list", "--count", f"{before}..HEAD"]).stdout.strip() or "0" + return f"{count} new commit(s)" + + +# ----------------------------------------------------------------------------- Ollama in Docker + + +@dataclass +class Container: + id: str + name: str + image: str # as configured, e.g. ollama/ollama:latest + info: dict = field(repr=False) + + @property + def compose(self) -> dict | None: + labels = (self.info.get("Config") or {}).get("Labels") or {} + project, service = labels.get("com.docker.compose.project"), labels.get("com.docker.compose.service") + if not project or not service: + return None + files = [f for f in str(labels.get("com.docker.compose.project.config_files", "")).split(",") if f] + return {"project": project, "service": service, "files": files, + "workdir": labels.get("com.docker.compose.project.working_dir", "")} # fmt: skip + + +def docker_available() -> bool: + return shutil.which("docker") is not None and run(["docker", "info", "--format", "{{.ID}}"]).returncode == 0 + + +def find_container(host: str) -> Container | None: + """The Ollama container that serves `host` (a local address), if Ollama runs in Docker.""" + parts = urlsplit(host) + if parts.hostname not in ("localhost", "127.0.0.1", "::1", "0.0.0.0", None): + return None + port = str(parts.port or 11434) + ids = run(["docker", "ps", "-q"]).stdout.split() + if not ids: + return None + infos = json.loads(run(["docker", "inspect", *ids]).stdout or "[]") + found = [] + for info in infos: + image = (info.get("Config") or {}).get("Image", "") + if not image.startswith(IMAGE): + continue + bindings = ((info.get("HostConfig") or {}).get("PortBindings") or {}).get("11434/tcp") or [] + if any(str(b.get("HostPort")) == port for b in bindings): + found.append(Container(info["Id"][:12], info["Name"].lstrip("/"), image, info)) + return found[0] if len(found) == 1 else None + + +def set_compose_image(text: str, service: str, image: str) -> tuple[str, str]: + """The compose file with the service's image changed, and the image it had.""" + lines = text.splitlines(keepends=True) + services = next((i for i, line in enumerate(lines) if re.match(r"services:\s*(#.*)?$", line)), None) + if services is None: + raise UpdateError("no services: section") + start = indent = None + for i in range(services + 1, len(lines)): + m = re.match(rf"(\s+)['\"]?{re.escape(service)}['\"]?:\s*(#.*)?$", lines[i]) + if m: + start, indent = i, len(m.group(1)) + break + if lines[i].strip() and not lines[i][0].isspace() and not lines[i].lstrip().startswith("#"): + break # the next top-level key: services ended + if start is None or indent is None: + raise UpdateError(f"no service {service} in it") + for i in range(start + 1, len(lines)): + line = lines[i] + if line.strip() and not line.lstrip().startswith("#") and len(line) - len(line.lstrip()) <= indent: + break # the next service + m = re.match(r"(\s+image:\s*)(['\"]?)([^'\"#\s]+)\2(\s*(#.*)?)$", line.rstrip("\n")) + if m: + if "${" in line: + raise UpdateError(f"the image comes from a variable ({m.group(3)}): set it to {image}") + newline = "\n" if line.endswith("\n") else "" + lines[i] = f"{m.group(1)}{m.group(2)}{image}{m.group(2)}{m.group(4)}{newline}" + return "".join(lines), m.group(3) + raise UpdateError(f"service {service} has no image: line (it may be built from a Dockerfile)") + + +def run_args(info: dict, image_info: dict, image: str) -> list[str]: + """`docker run` arguments that recreate a container with another image and the same settings.""" + config, host = info.get("Config") or {}, info.get("HostConfig") or {} + base = image_info.get("Config") or {} + args = ["docker", "run", "-d", "--name", info["Name"].lstrip("/")] + restart = host.get("RestartPolicy") or {} + if restart.get("Name") not in (None, "", "no"): + retries = restart.get("MaximumRetryCount") or 0 + args += ["--restart", restart["Name"] + (f":{retries}" if restart["Name"] == "on-failure" and retries else "")] + for port, bindings in (host.get("PortBindings") or {}).items(): + target = port.removesuffix("/tcp") + for b in bindings or []: + ip = b.get("HostIp") or "" + args += ["-p", f"{ip + ':' if ip else ''}{b.get('HostPort', '')}:{target}"] + for m in info.get("Mounts") or []: + source = m.get("Name") if m.get("Type") == "volume" else m.get("Source") + if source and m.get("Type") in ("volume", "bind"): + args += ["-v", f"{source}:{m['Destination']}" + ("" if m.get("RW", True) else ":ro")] + default_env = set(base.get("Env") or []) + for entry in config.get("Env") or []: + if entry not in default_env: # only what was set for this container; the new image brings its own PATH + args += ["-e", entry] + for request in host.get("DeviceRequests") or []: + if ["gpu"] in (request.get("Capabilities") or []): + ids = request.get("DeviceIDs") or [] + count = request.get("Count") or 0 + args += ["--gpus", f"device={','.join(ids)}" if ids else ("all" if count == -1 else str(count))] + for device in host.get("Devices") or []: + args += ["--device", f"{device['PathOnHost']}:{device['PathInContainer']}"] + if host.get("Runtime") not in (None, "", "runc"): + args += ["--runtime", host["Runtime"]] + network = host.get("NetworkMode") or "default" + if network not in ("default", "bridge") and not network.startswith("container:"): + args += ["--network", network] + endpoint = ((info.get("NetworkSettings") or {}).get("Networks") or {}).get(network) or {} + name, short_id = info["Name"].lstrip("/"), info["Id"][:12] + for alias in endpoint.get("Aliases") or []: + if alias not in (name, short_id): + args += ["--network-alias", alias] + for host_entry in host.get("ExtraHosts") or []: + args += ["--add-host", host_entry] + base_labels = base.get("Labels") or {} + for key, value in (config.get("Labels") or {}).items(): + if base_labels.get(key) != value: + args += ["--label", f"{key}={value}"] + entrypoint, cmd = config.get("Entrypoint") or [], config.get("Cmd") or [] + if entrypoint and entrypoint != (base.get("Entrypoint") or []): + args += ["--entrypoint", entrypoint[0]] + cmd = [*entrypoint[1:], *cmd] + elif cmd == (base.get("Cmd") or []): + cmd = [] + return [*args, image, *cmd] + + +def wait_for_version(host: str, wanted: str, timeout: float = START_TIMEOUT) -> bool: + from lcode.ollama import Ollama, OllamaError + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + if Ollama(host).version() == wanted.removesuffix("-rocm"): + return True + except OllamaError: + pass + time.sleep(2) + return False + + +def recreate(container: Container, image: str, host: str, say: Callable[[str], None]) -> None: + """Replace a plain container with one on `image`; put the old one back if that fails.""" + image_info = json.loads(run(["docker", "image", "inspect", container.info["Image"]]).stdout or "[{}]")[0] + args = run_args(container.info, image_info, image) + backup = f"{container.name}-before-lcode-update" + for step in (["docker", "stop", container.name], ["docker", "rename", container.name, backup]): + r = run(step) + if r.returncode != 0: + raise UpdateError(f"{' '.join(step[:2])} failed: {(r.stderr or r.stdout).strip()}") + say(f"$ {' '.join(args)}") + started = run(args) + if started.returncode == 0 and wait_for_version(host, image.rsplit(":", 1)[1]): + run(["docker", "rm", backup]) + return + problem = (started.stderr or started.stdout).strip() or "it didn't answer with the new version" + run(["docker", "rm", "-f", container.name]) + run(["docker", "rename", backup, container.name]) + run(["docker", "start", container.name]) + raise UpdateError(f"the new container didn't start ({problem[:300]}); the old one is running again") + + +def recreate_with_compose(container: Container, image: str, host: str, say: Callable[[str], None]) -> None: + command, project_dir = compose_up(container.compose or {}) + say(f"$ {' '.join(command)}") + if stream(command, cwd=project_dir) != 0: + raise UpdateError("docker compose up failed") + if not wait_for_version(host, image.rsplit(":", 1)[1]): + raise UpdateError(f"Ollama didn't answer with version {image.rsplit(':', 1)[1]} after the restart") + + +def compose_up(compose: dict) -> tuple[list[str], Path]: + """`docker compose up` for just the Ollama service of its project, and where to run it.""" + files = [Path(f) for f in compose["files"]] + project_dir = Path(compose["workdir"]) if compose.get("workdir") else files[0].parent + command = ["docker", "compose", "-p", compose["project"], "--project-directory", str(project_dir)] + for f in files: + command += ["-f", str(f)] + return [*command, "up", "-d", "--no-deps", compose["service"]], project_dir + + +def compose_change(container: Container, image: str) -> tuple[Path, str, str]: + """Which compose file defines the service's image, and its new text.""" + compose = container.compose or {} + problems = [] + for path in reversed([Path(f) for f in compose.get("files") or []]): # later files override earlier ones + try: + text, old = set_compose_image(path.read_text(), compose["service"], image) + return path, text, old + except (OSError, UpdateError) as e: + problems.append(f"{path}: {e}") + raise UpdateError("couldn't find the image to change: " + "; ".join(problems)) + + +# ----------------------------------------------------------------------------- lcode update + + +def command(console, what: str = "", check: bool = False, yes: bool = False) -> int: + """Update lcode and Ollama (or `what`: "lcode" or "ollama"). Returns the exit code.""" + from rich.prompt import Confirm + + def ask(question: str) -> bool: + if yes: + return True + if not sys.stdin.isatty(): + console.print("[dim]Not asking without a terminal: run lcode update -y to go ahead.[/]") + return False + return Confirm.ask(question, default=True, console=console) + + failed = False + for part, step in (("lcode", update_lcode), ("ollama", update_ollama)): + if what and what != part: + continue + try: + step(console, check, ask) + except UpdateError as e: + console.print(f"[red]✗ {escape(str(e))}[/]") + failed = True + console.print() + return 1 if failed else 0 + + +def escape(text: str) -> str: + from rich.markup import escape as rich_escape + + return rich_escape(text) + + +def update_lcode(console, check: bool, ask: Callable[[str], bool]) -> None: + install = lcode_install() + if install.kind == "checkout" and install.path is not None: + state = checkout_state(install.path) + where = f"lcode {__version__} · {install.describe()}, branch {state.branch}" + if not state.upstream: + console.print(f"{where}: no upstream branch to update from.") + elif not state.behind: + console.print(f"[green]✓[/] {where}: up to date with {state.upstream}.") + elif check: + console.print(f"{where}: {state.behind} new commit(s) on {state.upstream}.") + elif state.dirty or state.branch not in ("main", "master"): + why = "has uncommitted changes" if state.dirty else f"is on branch {state.branch}" + console.print( + f"{where}: {state.behind} new commit(s) on {state.upstream}, but the checkout {why}. Update it yourself with git pull." + ) + elif ask(f"Pull {state.behind} new commit(s) into {install.path}?"): + receipt = (Path(sys.prefix) / "uv-receipt.toml").exists() + console.print(f"[green]✓[/] lcode updated: {update_checkout(install.path, receipt)}.") + return + latest = latest_lcode() + if version_key(latest) <= version_key(__version__): + console.print(f"[green]✓[/] lcode {__version__} is the newest version ({install.describe()}).") + return + if install.command is None: + console.print( + f"lcode {__version__} → {latest} is out. You run lcode with uvx: use uvx --refresh --from lcode-cli lcode" + ) + return + shown = " ".join(install.command) + if check: + console.print(f"lcode {__version__} → {latest} is out ({install.describe()}): {shown}") + return + if not ask(f"Update lcode {__version__} → {latest} with `{shown}`?"): + return + if stream(install.command) != 0: + raise UpdateError(f"{shown} failed") + console.print("[green]✓[/] lcode updated; the new version runs from the next `lcode`.") + if install.kind == "brew": + console.print( + "[dim]Homebrew's formula follows a release by up to a day: if it's still the old version, try again later.[/]" + ) + + +def update_ollama(console, check: bool, ask: Callable[[str], bool]) -> None: + from lcode import config + from lcode.hardware import detect + from lcode.ollama import Ollama, OllamaError + + cfg = config.load() + if cfg["backend"] != "ollama": + console.print(f"lcode uses another model server (backend = {cfg['backend']}): update it there.") + return + host = cfg["ollama_host"] + try: + current = Ollama(host).version() + except OllamaError: + current = "" + container = find_container(host) if docker_available() else None + if container is None: + if not current: + raise UpdateError(f"Ollama doesn't answer at {host}: start it, then run lcode update again") + latest = latest_ollama() + local = urlsplit(host).hostname in ("localhost", "127.0.0.1", "::1", "0.0.0.0", None) + if version_key(latest) <= version_key(current): + console.print(f"[green]✓[/] Ollama {current} is the newest version.") + elif not local: + console.print(f"Ollama {current} → {latest} is out. It runs on {host}: update it on that machine.") + else: + hint = NATIVE.get(detect().os, NATIVE["linux"]) + console.print( + f"Ollama {current} → {latest} is out. Ollama isn't in a Docker container here, so update it with:\n {hint}" + ) + return + latest = latest_ollama(image_flavor(container.image)) + image = f"{IMAGE}:{latest}" + compose = container.compose + where = f"container {container.name}, image {container.image}" + ( + f", Docker Compose project {compose['project']}" if compose else "" + ) + console.print(f"Ollama {current or '?'} · {where}. Newest on Docker Hub: {latest}.") + if container.image == image and current == latest.removesuffix("-rocm"): + console.print(f"[green]✓[/] Ollama is up to date, on {image}.") + return + if current == latest.removesuffix("-rocm"): + console.print( + f"[dim]It already runs {current}, but as {container.image}: pinning it to {image} shows the version in docker ps.[/]" + ) + change = compose_change(container, image) if compose else None + plan = [f"docker pull {image}"] + if change: + path, _, old = change + plan.append(f"in {path}, service {compose['service']}: image {old} → {image}") # type: ignore[index] + plan.append(f"docker compose up -d --no-deps {compose['service']}") # type: ignore[index] + else: + plan.append( + f"recreate container {container.name} on {image}, with the same name, ports, volumes, environment, GPUs, network and restart policy (the old one is kept until the new one answers)" + ) + console.print("\n".join(f" {i}. {escape(step)}" for i, step in enumerate(plan, 1))) + if check: + return + console.print("[dim]Ollama restarts: requests running now stop, and models load again on their next request.[/]") + if not ask("Update Ollama?"): + return + if stream(["docker", "pull", image]) != 0: + raise UpdateError(f"docker pull {image} failed") + + def say(text: str) -> None: + console.print(f"[dim]{escape(text)}[/]") + + if change: + path, text, _ = change + original = path.read_text() + path.write_text(text) + try: + recreate_with_compose(container, image, host, say) + except UpdateError: + path.write_text(original) + say(f"put {path} back the way it was") + command, workdir = compose_up(compose) # type: ignore[arg-type] + stream(command, cwd=workdir) + raise + else: + recreate(container, image, host, say) + console.print(f"[green]✓[/] Ollama {current or '?'} → {latest.removesuffix('-rocm')}, running as {image}.") + old_image = container.info.get("Image", "") + if old_image and not run(["docker", "ps", "-aq", "--filter", f"ancestor={old_image}"]).stdout.strip(): + size = run(["docker", "image", "inspect", old_image, "--format", "{{.Size}}"]).stdout.strip() + gb = f" ({int(size) / 1e9:.1f} GB)" if size.isdigit() else "" + console.print( + f"[dim]The previous image is still on disk{gb}. To remove it: docker image rm {old_image[7:19]}[/]" + ) diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 0000000..98a199c --- /dev/null +++ b/tests/test_update.py @@ -0,0 +1,303 @@ +import io +import json +import subprocess + +import pytest +from rich.console import Console + +from lcode import __version__, update +from lcode.update import Container, Install, UpdateError + +COMPOSE = """\ +name: lab +services: + db: + image: postgres:16 + ollama: + # the model server + image: "ollama/ollama:latest" # keep in sync + container_name: lab-ollama + ports: + - "11434:11434" + web: + build: . +volumes: + models: {} +""" + + +def hub(*names, next_page=None): + return {"results": [{"name": n} for n in names], "next": next_page} + + +def inspect(name="lab-ollama", image="ollama/ollama:latest", labels=None, network="lab_default"): + return { + "Id": "ce403d03b71e" + "0" * 52, + "Name": f"/{name}", + "Image": "sha256:" + "3886" * 16, + "Config": { + "Image": image, + "Env": ["OLLAMA_CONTEXT_LENGTH=32768", "OLLAMA_HOST=0.0.0.0:11434", "PATH=/usr/bin"], + "Cmd": ["serve"], + "Entrypoint": ["/bin/ollama"], + "Labels": {"org.opencontainers.image.version": "24.04", "team": "ml", **(labels or {})}, + }, + "HostConfig": { + "RestartPolicy": {"Name": "unless-stopped", "MaximumRetryCount": 0}, + "PortBindings": {"11434/tcp": [{"HostIp": "", "HostPort": "11434"}]}, + "DeviceRequests": [{"Driver": "", "Count": -1, "DeviceIDs": None, "Capabilities": [["gpu"]]}], + "Runtime": "runc", + "NetworkMode": network, + }, + "NetworkSettings": {"Networks": {network: {"Aliases": [name, "ollama", "ce403d03b71e"]}}}, + "Mounts": [{"Type": "volume", "Name": "models", "Destination": "/root/.ollama", "RW": True}], + } + + +IMAGE_INFO = { + "Config": { + "Env": ["PATH=/usr/bin", "OLLAMA_HOST=0.0.0.0:11434"], + "Cmd": ["serve"], + "Entrypoint": ["/bin/ollama"], + "Labels": {"org.opencontainers.image.version": "24.04"}, + } +} + + +# ----------------------------------------------------------------------------- versions + + +def test_the_newest_release_comes_from_docker_hub(monkeypatch): + pages = { + update.HUB_TAGS: hub( + "0.40.0-rc5-rocm", "0.40.0-rc5", "rocm", "latest", "0.35.1-rocm", "0.35.1", next_page="p2" + ), + "p2": hub("0.35.1-rc2", "0.35.0", "0.34.4", "0.9.10"), + } + monkeypatch.setattr(update, "get_json", lambda url, **params: pages[url]) + assert update.latest_ollama() == "0.35.1" # not the release candidates, not "latest" + assert update.latest_ollama("rocm") == "0.35.1-rocm" + pages[update.HUB_TAGS] = hub("latest", "rocm") + pages["p2"] = hub() + with pytest.raises(UpdateError, match="lists no released ollama/ollama versions"): + update.latest_ollama() + assert update.version_key("0.35.1") > update.version_key("0.9.10") + assert ( + update.image_flavor("ollama/ollama:rocm") == "rocm" + and update.image_flavor("ollama/ollama:0.35.1-rocm") == "rocm" + ) + assert update.image_flavor("ollama/ollama") == "" and update.image_flavor("ollama/ollama:latest") == "" + + +def test_how_lcode_was_installed(tmp_path): + editable = json.dumps({"url": "file:///home/me/lcode", "dir_info": {"editable": True}}) + assert update.lcode_install(tmp_path, editable).path.as_posix() == "/home/me/lcode" + (tmp_path / "uv-receipt.toml").write_text("[tool]\n") + assert update.lcode_install(tmp_path, "").command == ["uv", "tool", "upgrade", "lcode-cli"] + brew = tmp_path / "Cellar" / "lcode" / "0.20.0" / "libexec" + brew.mkdir(parents=True) + assert update.lcode_install(brew, "").kind == "brew" + pipx = tmp_path / "pipx" / "venvs" / "lcode-cli" + pipx.mkdir(parents=True) + assert update.lcode_install(pipx, "").command == ["pipx", "upgrade", "lcode-cli"] + uvx = tmp_path / ".cache" / "uv" / "archive-v0" / "abc" + uvx.mkdir(parents=True) + assert update.lcode_install(uvx, "").kind == "uvx" and update.lcode_install(uvx, "").command is None + plain = tmp_path / "venv" + plain.mkdir() + assert update.lcode_install(plain, "").command[-3:] == ["install", "--upgrade", "lcode-cli"] + + +# ----------------------------------------------------------------------------- Docker Compose + + +def test_the_compose_file_gets_the_version_tag(): + text, old = update.set_compose_image(COMPOSE, "ollama", "ollama/ollama:0.35.1") + assert old == "ollama/ollama:latest" + assert ' image: "ollama/ollama:0.35.1" # keep in sync\n' in text + assert text.replace('"ollama/ollama:0.35.1"', '"ollama/ollama:latest"') == COMPOSE # nothing else changed + with pytest.raises(UpdateError, match="no service llama in it"): + update.set_compose_image(COMPOSE, "llama", "x") + with pytest.raises(UpdateError, match="service web has no image: line"): + update.set_compose_image(COMPOSE, "web", "x") + variable = COMPOSE.replace('"ollama/ollama:latest"', "ollama/ollama:${OLLAMA_VERSION:-latest}") + with pytest.raises(UpdateError, match="the image comes from a variable"): + update.set_compose_image(variable, "ollama", "ollama/ollama:0.35.1") + + +def compose_container(tmp_path): + path = tmp_path / "compose.yaml" + path.write_text(COMPOSE) + labels = { + "com.docker.compose.project": "lab", + "com.docker.compose.service": "ollama", + "com.docker.compose.project.config_files": str(path), + "com.docker.compose.project.working_dir": str(tmp_path), + } + return Container("ce403d03b71e", "lab-ollama", "ollama/ollama:latest", inspect(labels=labels)), path + + +@pytest.fixture +def docker(monkeypatch): + """Ollama 0.32.15 in Docker, 0.35.1 on Docker Hub, and a record of the commands lcode runs.""" + calls = {"run": [], "stream": [], "stream_codes": {}} + + def fake_run(argv, cwd=None, timeout=120): + calls["run"].append(argv) + out = "" + if argv[:3] == ["docker", "image", "inspect"]: + out = json.dumps([IMAGE_INFO]) if "--format" not in argv else "3500000000" + return subprocess.CompletedProcess(argv, 0, out, "") + + def fake_stream(argv, cwd=None): + calls["stream"].append(argv) + return calls["stream_codes"].get(argv[1], 0) + + monkeypatch.setattr(update, "run", fake_run) + monkeypatch.setattr(update, "stream", fake_stream) + monkeypatch.setattr(update, "docker_available", lambda: True) + monkeypatch.setattr(update, "latest_ollama", lambda flavor="": "0.35.1") + monkeypatch.setattr(update, "wait_for_version", lambda host, wanted, timeout=0: True) + monkeypatch.setattr("lcode.ollama.Ollama.version", lambda self: "0.32.15") + monkeypatch.setattr("sys.stdin.isatty", lambda: True, raising=False) + return calls + + +def output_of(console): + return console.file.getvalue() + + +def test_a_compose_container_is_updated_through_its_compose_file(docker, tmp_path, monkeypatch): + container, path = compose_container(tmp_path) + monkeypatch.setattr(update, "find_container", lambda host: container) + console = Console(file=io.StringIO(), width=200) + assert update.command(console, "ollama", yes=True) == 0 + assert docker["stream"][0] == ["docker", "pull", "ollama/ollama:0.35.1"] + assert docker["stream"][1][:4] == ["docker", "compose", "-p", "lab"] + assert docker["stream"][1][-4:] == ["up", "-d", "--no-deps", "ollama"] + assert 'image: "ollama/ollama:0.35.1"' in path.read_text() + text = output_of(console) + assert f"in {path}, service ollama: image ollama/ollama:latest → ollama/ollama:0.35.1" in text + assert "✓ Ollama 0.32.15 → 0.35.1, running as ollama/ollama:0.35.1." in text + assert "The previous image is still on disk (3.5 GB). To remove it: docker image rm 388638863886" in text + + +def test_a_failed_compose_update_puts_the_file_back(docker, tmp_path, monkeypatch): + container, path = compose_container(tmp_path) + monkeypatch.setattr(update, "find_container", lambda host: container) + docker["stream_codes"]["compose"] = 1 + console = Console(file=io.StringIO(), width=200) + assert update.command(console, "ollama", yes=True) == 1 + assert path.read_text() == COMPOSE and "✗ docker compose up failed" in output_of(console) + + +def test_check_and_no_terminal_change_nothing(docker, tmp_path, monkeypatch): + container, path = compose_container(tmp_path) + monkeypatch.setattr(update, "find_container", lambda host: container) + console = Console(file=io.StringIO(), width=200) + assert update.command(console, "ollama", check=True) == 0 + monkeypatch.setattr("sys.stdin.isatty", lambda: False, raising=False) + assert update.command(console, "ollama") == 0 + assert docker["stream"] == [] and path.read_text() == COMPOSE + assert "Not asking without a terminal: run lcode update -y" in output_of(console) + + +# ----------------------------------------------------------------------------- a plain container + + +def test_the_same_settings_for_the_new_container(): + args = update.run_args(inspect(), IMAGE_INFO, "ollama/ollama:0.35.1") + assert args == [ + "docker", "run", "-d", "--name", "lab-ollama", "--restart", "unless-stopped", + "-p", "11434:11434", "-v", "models:/root/.ollama", "-e", "OLLAMA_CONTEXT_LENGTH=32768", + "--gpus", "all", "--network", "lab_default", "--network-alias", "ollama", + "--label", "team=ml", "ollama/ollama:0.35.1", + ] # fmt: skip + info = inspect(network="default") + info["HostConfig"]["DeviceRequests"][0].update(Count=0, DeviceIDs=["1"]) + info["HostConfig"]["PortBindings"]["11434/tcp"][0]["HostIp"] = "127.0.0.1" + info["Config"]["Cmd"] = ["serve", "--verbose"] + args = update.run_args(info, IMAGE_INFO, "ollama/ollama:0.35.1") + assert "--network" not in args and args[args.index("--gpus") + 1] == "device=1" + assert "127.0.0.1:11434:11434" in args and args[-3:] == ["ollama/ollama:0.35.1", "serve", "--verbose"] + + +def test_a_plain_container_is_recreated_and_put_back_if_needed(docker, monkeypatch): + container = Container("ce403d03b71e", "my-ollama", "ollama/ollama:latest", inspect(name="my-ollama")) + monkeypatch.setattr(update, "find_container", lambda host: container) + console = Console(file=io.StringIO(), width=200) + assert update.command(console, "ollama", yes=True) == 0 + commands = [c[:3] for c in docker["run"] if c[0] == "docker"] + assert ["docker", "stop", "my-ollama"] in commands + assert ["docker", "rename", "my-ollama"] in commands and [ + "docker", + "rm", + "my-ollama-before-lcode-update", + ] in commands + created = next(c for c in docker["run"] if c[:2] == ["docker", "run"]) + assert created[created.index("--name") + 1] == "my-ollama" and "ollama/ollama:0.35.1" in created + docker["run"].clear() + monkeypatch.setattr(update, "wait_for_version", lambda host, wanted, timeout=0: False) + assert update.command(console, "ollama", yes=True) == 1 + assert docker["run"][-3:] == [ + ["docker", "rm", "-f", "my-ollama"], + ["docker", "rename", "my-ollama-before-lcode-update", "my-ollama"], + ["docker", "start", "my-ollama"], + ] + assert "the old one is running again" in output_of(console) + + +def test_up_to_date_and_pinned(docker, monkeypatch): + container = Container("ce403d03b71e", "my-ollama", "ollama/ollama:0.35.1", inspect(image="ollama/ollama:0.35.1")) + monkeypatch.setattr(update, "find_container", lambda host: container) + monkeypatch.setattr("lcode.ollama.Ollama.version", lambda self: "0.35.1") + console = Console(file=io.StringIO(), width=200) + assert update.command(console, "ollama", yes=True) == 0 + assert docker["stream"] == [] and "✓ Ollama is up to date, on ollama/ollama:0.35.1." in output_of(console) + + +def test_ollama_outside_docker_gets_instructions(docker, monkeypatch): + monkeypatch.setattr(update, "find_container", lambda host: None) + console = Console(file=io.StringIO(), width=200) + assert update.command(console, "ollama", yes=True) == 0 + assert "Ollama 0.32.15 → 0.35.1 is out" in output_of(console) and docker["stream"] == [] + + +# ----------------------------------------------------------------------------- lcode itself + + +def test_lcode_updates_the_way_it_was_installed(docker, monkeypatch): + monkeypatch.setattr(update, "lcode_install", lambda: Install("uv", ["uv", "tool", "upgrade", "lcode-cli"])) + monkeypatch.setattr(update, "latest_lcode", lambda: "99.0.0") + console = Console(file=io.StringIO(), width=200) + assert update.command(console, "lcode", yes=True) == 0 + assert docker["stream"] == [["uv", "tool", "upgrade", "lcode-cli"]] + monkeypatch.setattr(update, "latest_lcode", lambda: __version__) + update.command(console, "lcode", yes=True) + assert f"lcode {__version__} is the newest version (uv tool)" in output_of(console) + + +def test_a_checkout_is_pulled(tmp_path, monkeypatch): + def git(cwd, *args): + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) + + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(tmp_path / "gitconfig")) + (tmp_path / "gitconfig").write_text("[user]\n\tname = T\n\temail = t@example.com\n") + origin, clone = tmp_path / "origin.git", tmp_path / "lcode" + git(tmp_path, "init", "-q", "--bare", "-b", "main", str(origin)) + git(tmp_path, "clone", "-q", str(origin), str(clone)) + (clone / "a.txt").write_text("1\n") + git(clone, "add", "-A") + git(clone, "commit", "-qm", "one") + git(clone, "push", "-q", "origin", "main") + other = tmp_path / "other" + git(tmp_path, "clone", "-q", str(origin), str(other)) + (other / "a.txt").write_text("2\n") + git(other, "commit", "-qam", "two") + git(other, "push", "-q") + monkeypatch.setattr(update, "lcode_install", lambda: Install("checkout", path=clone)) + console = Console(file=io.StringIO(), width=200) + assert update.command(console, "lcode", check=True) == 0 + assert "1 new commit(s) on origin/main" in output_of(console) and (clone / "a.txt").read_text() == "1\n" + assert update.command(console, "lcode", yes=True) == 0 + assert (clone / "a.txt").read_text() == "2\n" and "lcode updated: 1 new commit(s)." in output_of(console) From 7fa5af96f15a88107e92b0a7206a1badd165aa95 Mon Sep 17 00:00:00 2001 From: nasser1941 <9349840+nasser1941@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:14:34 +0200 Subject: [PATCH 2/2] Test with wide consoles: macOS's long temporary paths wrapped the lines Co-Authored-By: Claude Opus 5.5 --- tests/test_update.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_update.py b/tests/test_update.py index 98a199c..309b7ed 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -170,7 +170,7 @@ def output_of(console): def test_a_compose_container_is_updated_through_its_compose_file(docker, tmp_path, monkeypatch): container, path = compose_container(tmp_path) monkeypatch.setattr(update, "find_container", lambda host: container) - console = Console(file=io.StringIO(), width=200) + console = Console(file=io.StringIO(), width=2000) assert update.command(console, "ollama", yes=True) == 0 assert docker["stream"][0] == ["docker", "pull", "ollama/ollama:0.35.1"] assert docker["stream"][1][:4] == ["docker", "compose", "-p", "lab"] @@ -186,7 +186,7 @@ def test_a_failed_compose_update_puts_the_file_back(docker, tmp_path, monkeypatc container, path = compose_container(tmp_path) monkeypatch.setattr(update, "find_container", lambda host: container) docker["stream_codes"]["compose"] = 1 - console = Console(file=io.StringIO(), width=200) + console = Console(file=io.StringIO(), width=2000) assert update.command(console, "ollama", yes=True) == 1 assert path.read_text() == COMPOSE and "✗ docker compose up failed" in output_of(console) @@ -194,7 +194,7 @@ def test_a_failed_compose_update_puts_the_file_back(docker, tmp_path, monkeypatc def test_check_and_no_terminal_change_nothing(docker, tmp_path, monkeypatch): container, path = compose_container(tmp_path) monkeypatch.setattr(update, "find_container", lambda host: container) - console = Console(file=io.StringIO(), width=200) + console = Console(file=io.StringIO(), width=2000) assert update.command(console, "ollama", check=True) == 0 monkeypatch.setattr("sys.stdin.isatty", lambda: False, raising=False) assert update.command(console, "ollama") == 0 @@ -225,7 +225,7 @@ def test_the_same_settings_for_the_new_container(): def test_a_plain_container_is_recreated_and_put_back_if_needed(docker, monkeypatch): container = Container("ce403d03b71e", "my-ollama", "ollama/ollama:latest", inspect(name="my-ollama")) monkeypatch.setattr(update, "find_container", lambda host: container) - console = Console(file=io.StringIO(), width=200) + console = Console(file=io.StringIO(), width=2000) assert update.command(console, "ollama", yes=True) == 0 commands = [c[:3] for c in docker["run"] if c[0] == "docker"] assert ["docker", "stop", "my-ollama"] in commands @@ -251,14 +251,14 @@ def test_up_to_date_and_pinned(docker, monkeypatch): container = Container("ce403d03b71e", "my-ollama", "ollama/ollama:0.35.1", inspect(image="ollama/ollama:0.35.1")) monkeypatch.setattr(update, "find_container", lambda host: container) monkeypatch.setattr("lcode.ollama.Ollama.version", lambda self: "0.35.1") - console = Console(file=io.StringIO(), width=200) + console = Console(file=io.StringIO(), width=2000) assert update.command(console, "ollama", yes=True) == 0 assert docker["stream"] == [] and "✓ Ollama is up to date, on ollama/ollama:0.35.1." in output_of(console) def test_ollama_outside_docker_gets_instructions(docker, monkeypatch): monkeypatch.setattr(update, "find_container", lambda host: None) - console = Console(file=io.StringIO(), width=200) + console = Console(file=io.StringIO(), width=2000) assert update.command(console, "ollama", yes=True) == 0 assert "Ollama 0.32.15 → 0.35.1 is out" in output_of(console) and docker["stream"] == [] @@ -269,7 +269,7 @@ def test_ollama_outside_docker_gets_instructions(docker, monkeypatch): def test_lcode_updates_the_way_it_was_installed(docker, monkeypatch): monkeypatch.setattr(update, "lcode_install", lambda: Install("uv", ["uv", "tool", "upgrade", "lcode-cli"])) monkeypatch.setattr(update, "latest_lcode", lambda: "99.0.0") - console = Console(file=io.StringIO(), width=200) + console = Console(file=io.StringIO(), width=2000) assert update.command(console, "lcode", yes=True) == 0 assert docker["stream"] == [["uv", "tool", "upgrade", "lcode-cli"]] monkeypatch.setattr(update, "latest_lcode", lambda: __version__) @@ -296,7 +296,7 @@ def git(cwd, *args): git(other, "commit", "-qam", "two") git(other, "push", "-q") monkeypatch.setattr(update, "lcode_install", lambda: Install("checkout", path=clone)) - console = Console(file=io.StringIO(), width=200) + console = Console(file=io.StringIO(), width=2000) assert update.command(console, "lcode", check=True) == 0 assert "1 new commit(s) on origin/main" in output_of(console) and (clone / "a.txt").read_text() == "1\n" assert update.command(console, "lcode", yes=True) == 0