ctx-tui 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ctx_tui-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Barlow
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ctx_tui-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: ctx-tui
3
+ Version: 0.1.0
4
+ Summary: Manage repo-scoped work contexts: fresh checkouts wrapped in terminal multiplexer sessions
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Dist: click>=8.1
8
+ Requires-Dist: textual>=8.2.8
9
+ Requires-Python: >=3.13
10
+ Description-Content-Type: text/markdown
11
+
12
+ # ctx
13
+
14
+ The way I write code has changed. With agents, I context-switch more, and the
15
+ time it takes to spin up or tear down a context became increasingly noticeable.
16
+ I didn't enjoy the `git worktree` UX, so I ended up maintaining multiple
17
+ long-lived clones of the same repository nestled in different tmux sessions to
18
+ work concurrently. This worked well for me, but it wasn't exactly elegant. So
19
+ here we are.
20
+
21
+ `ctx` lets you manage repo-scoped work contexts: each context is a fresh full
22
+ checkout on its own local branch, wrapped in a terminal multiplexer session
23
+ with your panes already laid out.
24
+
25
+
26
+ Disclaimer: this is fully vibe-coded.
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ uv tool install ctx-tui
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ Run `ctx` to manage contexts and repos interactively in the TUI (`?` lists
37
+ the keybindings). The same operations are available as subcommands:
38
+
39
+ ```sh
40
+ ctx repo add https://github.com/Michael-JB/papaya-nvim.git # once per repo
41
+ ctx new papaya-nvim my-cool-feature # fresh checkout + session, jump in
42
+ # ...work, commit, push...
43
+ ctx rm my-cool-feature # tear it all down again
44
+ ```
45
+
46
+ More detail:
47
+
48
+ ```sh
49
+ # List contexts with their repo, branch, and status:
50
+ ctx list
51
+
52
+ # Contexts branch off the up-to-date default branch. To base one on another branch:
53
+ ctx new papaya-nvim follow-up -b other-base
54
+
55
+ # Re-attach to a context, recreating its session if needed:
56
+ ctx open my-cool-feature
57
+
58
+ # List registered repos, or remove some (their contexts are left alone):
59
+ ctx repo list
60
+ ctx repo rm papaya-nvim
61
+ ```
62
+
63
+ ## Configuration
64
+
65
+ Optional, at `$XDG_CONFIG_HOME/ctx/config.toml`. Directories default under
66
+ `$XDG_DATA_HOME/ctx/`; the example shows the usual fallback paths. All fields
67
+ shown with their defaults, except the layout: that defaults to a single shell
68
+ pane, so a custom tree is shown instead.
69
+
70
+ ```toml
71
+ contexts_dir = "~/.local/share/ctx/contexts" # where checkouts live
72
+ repos_dir = "~/.local/share/ctx/repos" # internal storage for registered repos
73
+ branch_prefix = "" # work branch prefix, e.g. "jane/"
74
+ multiplexer = "tmux" # or "zellij" (requires zellij >= 0.44)
75
+
76
+ # The pane layout: a tree of panes and "row"/"column" splits ("row" = side
77
+ # by side, "column" = stacked). A pane runs `command` (default: a shell) in
78
+ # the checkout; at most one pane may set `focus`.
79
+ [layout]
80
+ split = "row"
81
+
82
+ [[layout.panes]]
83
+ split = "column"
84
+ [[layout.panes.panes]]
85
+ command = "lazygit"
86
+ [[layout.panes.panes]]
87
+ command = "nvim"
88
+
89
+ [[layout.panes]]
90
+ command = "claude"
91
+ focus = true
92
+ ```
93
+
94
+ ### Environment variables
95
+
96
+ Fresh checkouts don't carry untracked files like `.env`. All contexts of a
97
+ repo share the parent directory `<contexts_dir>/<repo>/`; you could, for
98
+ example, use a tool like [direnv](https://direnv.net) to export env vars in
99
+ all of a repo's contexts from a single `.envrc` there:
100
+
101
+ ```sh
102
+ echo 'export MY_SECRET=some-value' > <contexts_dir>/papaya-nvim/.envrc
103
+ direnv allow <contexts_dir>/papaya-nvim
104
+ ```
105
+
106
+ ## Contributing
107
+
108
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,97 @@
1
+ # ctx
2
+
3
+ The way I write code has changed. With agents, I context-switch more, and the
4
+ time it takes to spin up or tear down a context became increasingly noticeable.
5
+ I didn't enjoy the `git worktree` UX, so I ended up maintaining multiple
6
+ long-lived clones of the same repository nestled in different tmux sessions to
7
+ work concurrently. This worked well for me, but it wasn't exactly elegant. So
8
+ here we are.
9
+
10
+ `ctx` lets you manage repo-scoped work contexts: each context is a fresh full
11
+ checkout on its own local branch, wrapped in a terminal multiplexer session
12
+ with your panes already laid out.
13
+
14
+
15
+ Disclaimer: this is fully vibe-coded.
16
+
17
+ ## Install
18
+
19
+ ```sh
20
+ uv tool install ctx-tui
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ Run `ctx` to manage contexts and repos interactively in the TUI (`?` lists
26
+ the keybindings). The same operations are available as subcommands:
27
+
28
+ ```sh
29
+ ctx repo add https://github.com/Michael-JB/papaya-nvim.git # once per repo
30
+ ctx new papaya-nvim my-cool-feature # fresh checkout + session, jump in
31
+ # ...work, commit, push...
32
+ ctx rm my-cool-feature # tear it all down again
33
+ ```
34
+
35
+ More detail:
36
+
37
+ ```sh
38
+ # List contexts with their repo, branch, and status:
39
+ ctx list
40
+
41
+ # Contexts branch off the up-to-date default branch. To base one on another branch:
42
+ ctx new papaya-nvim follow-up -b other-base
43
+
44
+ # Re-attach to a context, recreating its session if needed:
45
+ ctx open my-cool-feature
46
+
47
+ # List registered repos, or remove some (their contexts are left alone):
48
+ ctx repo list
49
+ ctx repo rm papaya-nvim
50
+ ```
51
+
52
+ ## Configuration
53
+
54
+ Optional, at `$XDG_CONFIG_HOME/ctx/config.toml`. Directories default under
55
+ `$XDG_DATA_HOME/ctx/`; the example shows the usual fallback paths. All fields
56
+ shown with their defaults, except the layout: that defaults to a single shell
57
+ pane, so a custom tree is shown instead.
58
+
59
+ ```toml
60
+ contexts_dir = "~/.local/share/ctx/contexts" # where checkouts live
61
+ repos_dir = "~/.local/share/ctx/repos" # internal storage for registered repos
62
+ branch_prefix = "" # work branch prefix, e.g. "jane/"
63
+ multiplexer = "tmux" # or "zellij" (requires zellij >= 0.44)
64
+
65
+ # The pane layout: a tree of panes and "row"/"column" splits ("row" = side
66
+ # by side, "column" = stacked). A pane runs `command` (default: a shell) in
67
+ # the checkout; at most one pane may set `focus`.
68
+ [layout]
69
+ split = "row"
70
+
71
+ [[layout.panes]]
72
+ split = "column"
73
+ [[layout.panes.panes]]
74
+ command = "lazygit"
75
+ [[layout.panes.panes]]
76
+ command = "nvim"
77
+
78
+ [[layout.panes]]
79
+ command = "claude"
80
+ focus = true
81
+ ```
82
+
83
+ ### Environment variables
84
+
85
+ Fresh checkouts don't carry untracked files like `.env`. All contexts of a
86
+ repo share the parent directory `<contexts_dir>/<repo>/`; you could, for
87
+ example, use a tool like [direnv](https://direnv.net) to export env vars in
88
+ all of a repo's contexts from a single `.envrc` there:
89
+
90
+ ```sh
91
+ echo 'export MY_SECRET=some-value' > <contexts_dir>/papaya-nvim/.envrc
92
+ direnv allow <contexts_dir>/papaya-nvim
93
+ ```
94
+
95
+ ## Contributing
96
+
97
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "ctx-tui"
3
+ version = "0.1.0"
4
+ description = "Manage repo-scoped work contexts: fresh checkouts wrapped in terminal multiplexer sessions"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.13"
9
+ dependencies = [
10
+ "click>=8.1",
11
+ "textual>=8.2.8",
12
+ ]
13
+
14
+ [project.scripts]
15
+ ctx = "ctx.cli:main"
16
+
17
+ [build-system]
18
+ requires = ["uv_build>=0.7,<0.9"]
19
+ build-backend = "uv_build"
20
+
21
+ [tool.uv.build-backend]
22
+ module-name = "ctx"
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "mypy>=2.3.0",
27
+ "ruff>=0.16.0",
28
+ ]
29
+
30
+ [tool.ruff]
31
+ line-length = 100
32
+
33
+ [tool.ruff.lint]
34
+ select = [
35
+ "E", # pycodestyle errors
36
+ "W", # pycodestyle warnings
37
+ "F", # pyflakes
38
+ "I", # isort
39
+ "UP", # pyupgrade
40
+ "B", # bugbear
41
+ "SIM", # simplify
42
+ "PTH", # pathlib
43
+ "RUF",
44
+ ]
45
+
46
+ [tool.mypy]
47
+ strict = true
File without changes
@@ -0,0 +1,206 @@
1
+ import subprocess
2
+ import sys
3
+
4
+ import click
5
+
6
+ from ctx import contexts, repos
7
+ from ctx.config import Config, ConfigError, load_config
8
+ from ctx.layout import LayoutError
9
+ from ctx.multiplexer import Multiplexer, MultiplexerError, get_multiplexer
10
+
11
+
12
+ @click.group(invoke_without_command=True)
13
+ @click.version_option(package_name="ctx-tui")
14
+ @click.pass_context
15
+ def cli(click_ctx: click.Context) -> None:
16
+ """Manage repo-scoped work contexts."""
17
+ if click_ctx.invoked_subcommand is None:
18
+ click_ctx.invoke(tui)
19
+
20
+
21
+ @cli.command()
22
+ @click.argument("repo")
23
+ @click.argument("name")
24
+ @click.option("-b", "--branch", "base", help="Base branch (default: the repo's default branch).")
25
+ def new(repo: str, name: str, base: str | None) -> None:
26
+ """Create a context: fresh checkout of REPO on a new local branch."""
27
+ _create_and_open(load_config(), repo, name, base)
28
+
29
+
30
+ def _create_and_open(cfg: Config, repo: str, name: str, base: str | None) -> None:
31
+ try:
32
+ ctx = contexts.create_context(cfg, repo, name, base)
33
+ except (FileExistsError, FileNotFoundError) as exc:
34
+ raise click.ClickException(str(exc)) from exc
35
+ click.echo(f"created {ctx.qualified} at {ctx.path} on {contexts.current_branch(ctx)}")
36
+ try:
37
+ get_multiplexer(cfg.multiplexer, cfg.layout).open(ctx)
38
+ except MultiplexerError as exc:
39
+ raise click.ClickException(str(exc)) from exc
40
+
41
+
42
+ @cli.command("open")
43
+ @click.argument("name")
44
+ def open_(name: str) -> None:
45
+ """Attach to a context's session, recreating it if needed."""
46
+ cfg = load_config()
47
+ try:
48
+ ctx = contexts.find_context(cfg, name)
49
+ except LookupError as exc:
50
+ raise click.ClickException(str(exc)) from exc
51
+ try:
52
+ get_multiplexer(cfg.multiplexer, cfg.layout).open(ctx)
53
+ except MultiplexerError as exc:
54
+ raise click.ClickException(str(exc)) from exc
55
+
56
+
57
+ @cli.command("list")
58
+ def list_() -> None:
59
+ """List contexts with branch, dirtiness, and session state."""
60
+ cfg = load_config()
61
+ all_contexts = contexts.list_contexts(cfg)
62
+ if not all_contexts:
63
+ click.echo("no contexts")
64
+ return
65
+ rows = [("NAME", "REPO", "BRANCH", "STATUS")]
66
+ for ctx in all_contexts:
67
+ status = []
68
+ if contexts.is_dirty(ctx):
69
+ status.append("uncommitted changes")
70
+ unpushed = contexts.unpushed_commits(ctx)
71
+ if unpushed:
72
+ status.append(f"{len(unpushed)} unpushed commit(s)")
73
+ rows.append(
74
+ (
75
+ ctx.name,
76
+ ctx.repo,
77
+ contexts.current_branch(ctx),
78
+ ", ".join(status) or "clean",
79
+ )
80
+ )
81
+ widths = [max(len(row[column]) for row in rows) for column in range(len(rows[0]))]
82
+ for row in rows:
83
+ cells = zip(row, widths, strict=True)
84
+ click.echo(" ".join(cell.ljust(width) for cell, width in cells).rstrip())
85
+
86
+
87
+ @cli.command()
88
+ @click.argument("names", nargs=-1, required=True)
89
+ @click.option("--force", is_flag=True, help="Delete even with uncommitted or unpushed work.")
90
+ def rm(names: tuple[str, ...], force: bool) -> None:
91
+ """Delete contexts: kill their sessions and remove the checkouts."""
92
+ cfg = load_config()
93
+ mux = get_multiplexer(cfg.multiplexer, cfg.layout)
94
+ failed = False
95
+ for name in names:
96
+ error = _remove_one(cfg, mux, name, force)
97
+ if error:
98
+ click.echo(f"error: {error}", err=True)
99
+ failed = True
100
+ if failed:
101
+ sys.exit(1)
102
+
103
+
104
+ def _remove_one(cfg: Config, mux: Multiplexer, name: str, force: bool) -> str | None:
105
+ """Delete one context, returning an error message instead of raising."""
106
+ try:
107
+ ctx = contexts.find_context(cfg, name)
108
+ except LookupError as exc:
109
+ return str(exc)
110
+ if not force:
111
+ problems = []
112
+ if contexts.is_dirty(ctx):
113
+ problems.append("uncommitted changes")
114
+ unpushed = contexts.unpushed_commits(ctx)
115
+ if unpushed:
116
+ problems.append(f"{len(unpushed)} unpushed commit(s)")
117
+ if problems:
118
+ return f"{ctx.qualified} has {' and '.join(problems)}; use --force to delete anyway"
119
+ if mux.exists(ctx):
120
+ mux.kill(ctx)
121
+ contexts.remove_context(ctx)
122
+ click.echo(f"removed {ctx.qualified}")
123
+ return None
124
+
125
+
126
+ @cli.command()
127
+ @click.option("--exit", "exit_on_open", is_flag=True, help="Exit the TUI after opening a context.")
128
+ def tui(exit_on_open: bool) -> None:
129
+ """Manage contexts and repos interactively."""
130
+ from ctx.tui import CtxTui, NewRequest, OpenRequest
131
+
132
+ cfg = load_config()
133
+ mux = get_multiplexer(cfg.multiplexer, cfg.layout)
134
+ # When the multiplexer can open sessions in place (e.g. inside tmux),
135
+ # the TUI handles everything itself and exits with no request. The
136
+ # requests below are the fallback for terminal-takeover attaches.
137
+ match CtxTui(cfg, mux, exit_on_open=exit_on_open).run():
138
+ case OpenRequest(name=name):
139
+ try:
140
+ ctx = contexts.find_context(cfg, name)
141
+ mux.open(ctx)
142
+ except (LookupError, MultiplexerError) as exc:
143
+ raise click.ClickException(str(exc)) from exc
144
+ case NewRequest(repo=repo_name, name=name, base=base):
145
+ _create_and_open(cfg, repo_name, name, base)
146
+ case None:
147
+ pass
148
+
149
+
150
+ @cli.group()
151
+ def repo() -> None:
152
+ """Manage registered repositories."""
153
+
154
+
155
+ @repo.command("add")
156
+ @click.argument("url")
157
+ @click.option("--name", help="Registry name (default: derived from URL).")
158
+ def repo_add(url: str, name: str | None) -> None:
159
+ """Register a repository by cloning a local bare mirror of it."""
160
+ cfg = load_config()
161
+ try:
162
+ registered = repos.add_repo(cfg, url, name)
163
+ except FileExistsError as exc:
164
+ raise click.ClickException(str(exc)) from exc
165
+ click.echo(f"registered '{registered}'")
166
+
167
+
168
+ @repo.command("list")
169
+ def repo_list() -> None:
170
+ """List registered repositories."""
171
+ cfg = load_config()
172
+ for name in repos.repo_names(cfg):
173
+ click.echo(f"{name}\t{repos.repo_url(cfg, name)}")
174
+
175
+
176
+ @repo.command("rm")
177
+ @click.argument("names", nargs=-1, required=True)
178
+ def repo_rm(names: tuple[str, ...]) -> None:
179
+ """Remove registered repositories' mirrors (contexts are untouched)."""
180
+ cfg = load_config()
181
+ failed = False
182
+ for name in names:
183
+ try:
184
+ repos.remove_repo(cfg, name)
185
+ except FileNotFoundError as exc:
186
+ click.echo(f"error: {exc}", err=True)
187
+ failed = True
188
+ continue
189
+ click.echo(f"removed '{name}'")
190
+ if failed:
191
+ sys.exit(1)
192
+
193
+
194
+ def main() -> None:
195
+ try:
196
+ cli()
197
+ except subprocess.CalledProcessError as exc:
198
+ cmd = " ".join(map(str, exc.cmd))
199
+ click.echo(f"error: command failed ({cmd})", err=True)
200
+ sys.exit(exc.returncode or 1)
201
+ except LayoutError as exc:
202
+ click.echo(f"error: invalid layout: {exc}", err=True)
203
+ sys.exit(1)
204
+ except ConfigError as exc:
205
+ click.echo(f"error: invalid config: {exc}", err=True)
206
+ sys.exit(1)
@@ -0,0 +1,53 @@
1
+ import os
2
+ import tomllib
3
+ from dataclasses import dataclass, replace
4
+ from pathlib import Path
5
+
6
+ from ctx.layout import DEFAULT_LAYOUT, Node, parse_layout
7
+ from ctx.multiplexer import MultiplexerKind
8
+
9
+
10
+ def _xdg_dir(variable: str, fallback: Path) -> Path:
11
+ value = os.environ.get(variable, "")
12
+ return Path(value) if value else Path.home() / fallback
13
+
14
+
15
+ CONFIG_PATH = _xdg_dir("XDG_CONFIG_HOME", Path(".config")) / "ctx" / "config.toml"
16
+ _DATA_DIR = _xdg_dir("XDG_DATA_HOME", Path(".local") / "share") / "ctx"
17
+
18
+
19
+ class ConfigError(Exception):
20
+ pass
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Config:
25
+ contexts_dir: Path = _DATA_DIR / "contexts"
26
+ repos_dir: Path = _DATA_DIR / "repos"
27
+ branch_prefix: str = ""
28
+ multiplexer: MultiplexerKind = MultiplexerKind.TMUX
29
+ layout: Node = DEFAULT_LAYOUT
30
+
31
+
32
+ def load_config() -> Config:
33
+ if not CONFIG_PATH.exists():
34
+ return Config()
35
+ data = tomllib.loads(CONFIG_PATH.read_text())
36
+ cfg = Config()
37
+ if "contexts_dir" in data:
38
+ cfg = replace(cfg, contexts_dir=Path(data["contexts_dir"]).expanduser())
39
+ if "repos_dir" in data:
40
+ cfg = replace(cfg, repos_dir=Path(data["repos_dir"]).expanduser())
41
+ if "branch_prefix" in data:
42
+ cfg = replace(cfg, branch_prefix=str(data["branch_prefix"]))
43
+ if "multiplexer" in data:
44
+ raw = str(data["multiplexer"])
45
+ try:
46
+ kind = MultiplexerKind(raw)
47
+ except ValueError as exc:
48
+ valid = ", ".join(k.value for k in MultiplexerKind)
49
+ raise ConfigError(f"unknown multiplexer '{raw}' (supported: {valid})") from exc
50
+ cfg = replace(cfg, multiplexer=kind)
51
+ if "layout" in data:
52
+ cfg = replace(cfg, layout=parse_layout(data["layout"]))
53
+ return cfg
@@ -0,0 +1,91 @@
1
+ import shutil
2
+ import subprocess
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from ctx import repos
7
+ from ctx.config import Config
8
+ from ctx.git import git
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Context:
13
+ repo: str
14
+ name: str
15
+ path: Path
16
+
17
+ @property
18
+ def qualified(self) -> str:
19
+ return f"{self.repo}/{self.name}"
20
+
21
+
22
+ def context_path(cfg: Config, repo: str, name: str) -> Path:
23
+ return cfg.contexts_dir / repo / name
24
+
25
+
26
+ def list_contexts(cfg: Config) -> list[Context]:
27
+ if not cfg.contexts_dir.is_dir():
28
+ return []
29
+ found = []
30
+ for repo_dir in sorted(cfg.contexts_dir.iterdir()):
31
+ if not repo_dir.is_dir():
32
+ continue
33
+ for ctx_dir in sorted(repo_dir.iterdir()):
34
+ if (ctx_dir / ".git").exists():
35
+ found.append(Context(repo_dir.name, ctx_dir.name, ctx_dir))
36
+ return found
37
+
38
+
39
+ def find_context(cfg: Config, name: str) -> Context:
40
+ """Resolve a context name; names are globally unique."""
41
+ matches = [c for c in list_contexts(cfg) if c.name == name]
42
+ if not matches:
43
+ raise LookupError(f"no context '{name}'")
44
+ return matches[0]
45
+
46
+
47
+ def create_context(cfg: Config, repo: str, name: str, base: str | None = None) -> Context:
48
+ mirror = repos.repo_path(cfg, repo)
49
+ if not mirror.exists():
50
+ raise FileNotFoundError(f"repo '{repo}' is not registered (ctx repo add <url>)")
51
+ taken = next((c for c in list_contexts(cfg) if c.name == name), None)
52
+ if taken is not None:
53
+ raise FileExistsError(f"context name '{name}' is already used by {taken.qualified}")
54
+ path = context_path(cfg, repo, name)
55
+
56
+ if base is None:
57
+ repos.update_repo(cfg, repo)
58
+ base = repos.default_branch(cfg, repo)
59
+ fetch_base = False
60
+ else:
61
+ # The mirror only carries the default branch; fetch the base into the context.
62
+ fetch_base = True
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ git("clone", str(mirror), str(path))
65
+ git("remote", "set-url", "origin", repos.repo_url(cfg, repo), cwd=path)
66
+ if fetch_base:
67
+ try:
68
+ git("fetch", "origin", base, cwd=path)
69
+ except subprocess.CalledProcessError as exc:
70
+ remove_context(Context(repo, name, path))
71
+ raise FileNotFoundError(f"branch '{base}' not found on origin of '{repo}'") from exc
72
+ branch = f"{cfg.branch_prefix}{name}"
73
+ git("checkout", "--no-track", "-b", branch, f"origin/{base}", cwd=path)
74
+ return Context(repo, name, path)
75
+
76
+
77
+ def current_branch(ctx: Context) -> str:
78
+ return git("branch", "--show-current", cwd=ctx.path)
79
+
80
+
81
+ def is_dirty(ctx: Context) -> bool:
82
+ return bool(git("status", "--porcelain", cwd=ctx.path))
83
+
84
+
85
+ def unpushed_commits(ctx: Context) -> list[str]:
86
+ out = git("log", "--branches", "--not", "--remotes", "--oneline", cwd=ctx.path)
87
+ return out.splitlines() if out else []
88
+
89
+
90
+ def remove_context(ctx: Context) -> None:
91
+ shutil.rmtree(ctx.path)
@@ -0,0 +1,8 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+
5
+ def git(*args: str, cwd: Path | None = None) -> str:
6
+ """Run git, letting stderr (progress, errors) stream to the terminal."""
7
+ result = subprocess.run(["git", *args], cwd=cwd, check=True, stdout=subprocess.PIPE, text=True)
8
+ return result.stdout.strip()