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 +21 -0
- ctx_tui-0.1.0/PKG-INFO +108 -0
- ctx_tui-0.1.0/README.md +97 -0
- ctx_tui-0.1.0/pyproject.toml +47 -0
- ctx_tui-0.1.0/src/ctx/__init__.py +0 -0
- ctx_tui-0.1.0/src/ctx/cli.py +206 -0
- ctx_tui-0.1.0/src/ctx/config.py +53 -0
- ctx_tui-0.1.0/src/ctx/contexts.py +91 -0
- ctx_tui-0.1.0/src/ctx/git.py +8 -0
- ctx_tui-0.1.0/src/ctx/layout.py +63 -0
- ctx_tui-0.1.0/src/ctx/multiplexer.py +48 -0
- ctx_tui-0.1.0/src/ctx/multiplexers/__init__.py +0 -0
- ctx_tui-0.1.0/src/ctx/multiplexers/tmux.py +75 -0
- ctx_tui-0.1.0/src/ctx/multiplexers/zellij.py +125 -0
- ctx_tui-0.1.0/src/ctx/repos.py +58 -0
- ctx_tui-0.1.0/src/ctx/tui.py +522 -0
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).
|
ctx_tui-0.1.0/README.md
ADDED
|
@@ -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()
|