git-workspace-tui 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,6 @@
1
+ """Git-aware multi-repo terminal workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
7
+
git_workspace/cli.py ADDED
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from .executor import process_env, resolve_command
9
+ from .models import ExecMode
10
+ from .output import plan_items_for_action, print_plan, print_status
11
+ from .planner import build_plan
12
+ from .tui import GitWorkspace
13
+ from .workspace import load_workspace
14
+
15
+
16
+ def build_parser() -> argparse.ArgumentParser:
17
+ parser = argparse.ArgumentParser(
18
+ prog="gws",
19
+ description="Git-aware multi-repo terminal workspace",
20
+ )
21
+ parser.add_argument("--cwd", type=Path, default=None, help="workspace start directory")
22
+
23
+ sub = parser.add_subparsers(dest="command")
24
+
25
+ status = sub.add_parser("status", aliases=["st", "s"], help="show workspace status")
26
+ status.add_argument("profile", nargs="?")
27
+
28
+ plan = sub.add_parser("plan", help="show workspace action plan")
29
+ plan.add_argument("profile", nargs="?")
30
+
31
+ switch = sub.add_parser("switch", help="checkout target branches from a profile")
32
+ switch.add_argument("profile", nargs="?")
33
+
34
+ pull = sub.add_parser("pull", help="pull repositories that are safe to update")
35
+ pull.add_argument("profile", nargs="?")
36
+
37
+ sync = sub.add_parser("sync", help="switch then pull repositories that are safe to update")
38
+ sync.add_argument("profile", nargs="?")
39
+
40
+ exec_cmd = sub.add_parser("exec", help="execute a command in every selected repository")
41
+ exec_cmd.add_argument("tokens", nargs=argparse.REMAINDER, metavar="...")
42
+
43
+ sub.add_parser("tui", help="open the TUI")
44
+ return parser
45
+
46
+
47
+ def run_git(repo_path: Path, *args: str) -> int:
48
+ proc = subprocess.run(
49
+ ["git", "-C", str(repo_path), "--no-pager", *args],
50
+ text=True,
51
+ env=process_env(),
52
+ )
53
+ return proc.returncode
54
+
55
+
56
+ def do_switch(profile: str | None, start: Path | None) -> int:
57
+ workspace = load_workspace(start)
58
+ exit_code = 0
59
+ for item in build_plan(workspace, profile):
60
+ if item.action == "blocked":
61
+ print(f"skip {item.repo.name}: {item.note}")
62
+ exit_code = 1
63
+ continue
64
+ if item.current != item.target:
65
+ print(f"== {item.repo.name}: checkout {item.target} ==")
66
+ exit_code = run_git(item.repo.path, "checkout", item.target) or exit_code
67
+ return exit_code
68
+
69
+
70
+ def do_pull(profile: str | None, start: Path | None) -> int:
71
+ workspace = load_workspace(start)
72
+ exit_code = 0
73
+ for item in build_plan(workspace, profile):
74
+ if item.action == "blocked":
75
+ print(f"skip {item.repo.name}: {item.note}")
76
+ exit_code = 1
77
+ continue
78
+ if item.action == "skip pull":
79
+ print(f"skip {item.repo.name}: {item.note}")
80
+ continue
81
+ if item.action == "checkout + pull":
82
+ hint = "run switch first or use sync"
83
+ print(f"skip {item.repo.name}: target branch differs; {hint}")
84
+ exit_code = 1
85
+ continue
86
+ print(f"== {item.repo.name}: pull ==")
87
+ exit_code = run_git(item.repo.path, "pull", "--ff-only") or exit_code
88
+ return exit_code
89
+
90
+
91
+ def do_sync(profile: str | None, start: Path | None) -> int:
92
+ switch_code = do_switch(profile, start)
93
+ pull_code = do_pull(profile, start)
94
+ return switch_code or pull_code
95
+
96
+
97
+ def parse_exec_tokens(tokens: list[str], profiles: set[str]) -> tuple[str | None, list[str]]:
98
+ if not tokens:
99
+ return None, []
100
+ if tokens[0] == "--":
101
+ return None, tokens[1:]
102
+ if "--" in tokens:
103
+ separator = tokens.index("--")
104
+ before = tokens[:separator]
105
+ if len(before) > 1:
106
+ raise ValueError("exec accepts at most one profile before --")
107
+ return (before[0] if before else None), tokens[separator + 1 :]
108
+ if tokens[0] in profiles:
109
+ return tokens[0], tokens[1:]
110
+ return None, tokens
111
+
112
+
113
+ def do_exec(tokens: list[str], start: Path | None) -> int:
114
+ workspace = load_workspace(start)
115
+ try:
116
+ profile, command_tokens = parse_exec_tokens(tokens, set(workspace.config.profiles))
117
+ except ValueError as exc:
118
+ print(str(exc), file=sys.stderr)
119
+ return 2
120
+
121
+ command = " ".join(command_tokens).strip()
122
+ if not command:
123
+ print("gws exec requires a command", file=sys.stderr)
124
+ return 2
125
+
126
+ selected = plan_items_for_action(
127
+ workspace,
128
+ profile,
129
+ {"pull", "checkout + pull", "skip pull", "blocked"},
130
+ )
131
+ repos = [item.repo for item in selected] or list(workspace.repos)
132
+ exit_code = 0
133
+ for repo in repos:
134
+ print(f"== {repo.name} ==", flush=True)
135
+ resolved = resolve_command(command, repo, workspace.config, ExecMode.SHELL)
136
+ if resolved is None:
137
+ continue
138
+ proc = subprocess.run(resolved.args, cwd=str(resolved.cwd), text=True, env=process_env())
139
+ exit_code = proc.returncode or exit_code
140
+ return exit_code
141
+
142
+
143
+ def main(argv: list[str] | None = None) -> int:
144
+ parser = build_parser()
145
+ args = parser.parse_args(argv)
146
+ command = args.command
147
+
148
+ if command is None or command == "tui":
149
+ GitWorkspace(args.cwd).run(mouse=False)
150
+ return 0
151
+
152
+ workspace = load_workspace(args.cwd)
153
+ if command in {"status", "st", "s"}:
154
+ print_status(workspace, args.profile)
155
+ return 0
156
+ if command == "plan":
157
+ print_plan(workspace, args.profile)
158
+ return 0
159
+ if command == "switch":
160
+ return do_switch(args.profile, args.cwd)
161
+ if command == "pull":
162
+ return do_pull(args.profile, args.cwd)
163
+ if command == "sync":
164
+ return do_sync(args.profile, args.cwd)
165
+ if command == "exec":
166
+ return do_exec(args.tokens, args.cwd)
167
+ parser.print_help()
168
+ return 2
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+ from .models import ExecMode, ExecSettings, WorkspaceConfig
9
+
10
+ CONFIG_NAMES = ("workspace.yml", "workspace.yaml")
11
+ LOCAL_CONFIG_NAMES = ("workspace.local.yml", "workspace.local.yaml")
12
+
13
+
14
+ def find_config(start: Path) -> Path | None:
15
+ current = start.resolve()
16
+ if current.is_file():
17
+ current = current.parent
18
+ for directory in (current, *current.parents):
19
+ for name in CONFIG_NAMES:
20
+ candidate = directory / name
21
+ if candidate.exists():
22
+ return candidate
23
+ return None
24
+
25
+
26
+ def _read_yaml(path: Path) -> dict[str, Any]:
27
+ if not path.exists():
28
+ return {}
29
+ loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
30
+ if loaded is None:
31
+ return {}
32
+ if not isinstance(loaded, dict):
33
+ raise ValueError(f"{path} must contain a mapping")
34
+ return loaded
35
+
36
+
37
+ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
38
+ result = dict(base)
39
+ for key, value in override.items():
40
+ if isinstance(result.get(key), dict) and isinstance(value, dict):
41
+ result[key] = _deep_merge(result[key], value)
42
+ else:
43
+ result[key] = value
44
+ return result
45
+
46
+
47
+ def _as_mapping(value: Any, name: str) -> dict[str, Any]:
48
+ if value is None:
49
+ return {}
50
+ if not isinstance(value, dict):
51
+ raise ValueError(f"{name} must be a mapping")
52
+ return value
53
+
54
+
55
+ def _as_string_mapping(value: Any, name: str) -> dict[str, str]:
56
+ mapping = _as_mapping(value, name)
57
+ return {str(key): str(val) for key, val in mapping.items()}
58
+
59
+
60
+ def _normalize_repo_config(value: Any) -> dict[str, dict[str, str]]:
61
+ repos = _as_mapping(value, "repos")
62
+ normalized: dict[str, dict[str, str]] = {}
63
+ for name, raw in repos.items():
64
+ if raw is None:
65
+ normalized[str(name)] = {}
66
+ elif isinstance(raw, str):
67
+ normalized[str(name)] = {"path": raw}
68
+ elif isinstance(raw, dict):
69
+ normalized[str(name)] = {str(key): str(val) for key, val in raw.items()}
70
+ else:
71
+ raise ValueError(f"repos.{name} must be a mapping or string")
72
+ return normalized
73
+
74
+
75
+ def _normalize_profiles(value: Any) -> dict[str, dict[str, str]]:
76
+ profiles = _as_mapping(value, "profiles")
77
+ normalized: dict[str, dict[str, str]] = {}
78
+ for name, raw in profiles.items():
79
+ normalized[str(name)] = _as_string_mapping(raw, f"profiles.{name}")
80
+ return normalized
81
+
82
+
83
+ def _normalize_ignore(value: Any) -> tuple[str, ...]:
84
+ if value is None:
85
+ return ()
86
+ if not isinstance(value, list | tuple):
87
+ raise ValueError("workspace.ignore must be a list")
88
+ return tuple(str(item) for item in value)
89
+
90
+
91
+ def _normalize_exec(value: Any) -> ExecSettings:
92
+ raw = _as_mapping(value, "exec")
93
+ mode = str(raw.get("defaultMode", raw.get("default_mode", "shell"))).lower()
94
+ default_mode = ExecMode.GIT if mode == "git" else ExecMode.SHELL
95
+ shell = _as_mapping(raw.get("shell"), "exec.shell")
96
+ return ExecSettings(
97
+ default_mode=default_mode,
98
+ git_shortcuts=bool(raw.get("gitShortcuts", raw.get("git_shortcuts", True))),
99
+ interactive_shell=bool(shell.get("interactive", True)),
100
+ )
101
+
102
+
103
+ def load_config(start: Path | None = None) -> WorkspaceConfig:
104
+ cwd = (start or Path.cwd()).resolve()
105
+ config_file = find_config(cwd)
106
+ if config_file is None:
107
+ return WorkspaceConfig(root=cwd)
108
+
109
+ data = _read_yaml(config_file)
110
+ for local_name in LOCAL_CONFIG_NAMES:
111
+ local_file = config_file.parent / local_name
112
+ if local_file.exists():
113
+ data = _deep_merge(data, _read_yaml(local_file))
114
+
115
+ workspace = _as_mapping(data.get("workspace"), "workspace")
116
+ root_value = workspace.get("root", ".")
117
+ root = (config_file.parent / str(root_value)).resolve()
118
+
119
+ return WorkspaceConfig(
120
+ root=root,
121
+ config_file=config_file,
122
+ ignore=_normalize_ignore(workspace.get("ignore")),
123
+ repos=_normalize_repo_config(data.get("repos")),
124
+ profiles=_normalize_profiles(data.get("profiles")),
125
+ aliases=_as_string_mapping(data.get("aliases"), "aliases"),
126
+ exec_settings=_normalize_exec(data.get("exec")),
127
+ )
128
+
@@ -0,0 +1,208 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shlex
5
+ import signal
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from .git import git_aliases
11
+ from .models import ExecMode, Repo, WorkspaceConfig
12
+
13
+ BUILTIN_GIT_ALIASES: dict[str, str] = {
14
+ "g": "status -sb",
15
+ "gs": "status -sb",
16
+ "gst": "status -sb",
17
+ "gss": "status -s",
18
+ "ga": "add",
19
+ "gaa": "add --all",
20
+ "gb": "branch",
21
+ "gba": "branch -a",
22
+ "gbr": "branch --remote",
23
+ "gco": "checkout",
24
+ "gcb": "checkout -b",
25
+ "gsw": "switch",
26
+ "gswc": "switch -c",
27
+ "gd": "diff",
28
+ "gds": "diff --stat",
29
+ "gf": "fetch --all --prune",
30
+ "gl": "pull",
31
+ "gp": "push",
32
+ "gpl": "pull",
33
+ "gps": "push",
34
+ "glog": "log --oneline --decorate -20",
35
+ "grv": "remote -v",
36
+ }
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ResolvedCommand:
41
+ display: str
42
+ args: list[str]
43
+ cwd: Path
44
+ shell_mode: bool
45
+ expanded: str | None = None
46
+
47
+
48
+ def normalize_git_command(command: str) -> str:
49
+ command = command.strip()
50
+ if command.startswith("git "):
51
+ return command[4:].strip()
52
+ return command
53
+
54
+
55
+ def shell_program() -> str:
56
+ return os.environ.get("SHELL") or "/bin/sh"
57
+
58
+
59
+ def _source_if_readable(path: str) -> str:
60
+ return f'[ -r "{path}" ] && . "{path}" >/dev/null 2>&1 || true'
61
+
62
+
63
+ def _posix_shell_script(command: str, shell_name: str) -> str:
64
+ rc_files: tuple[str, ...]
65
+ prelude: list[str] = ["set +e"]
66
+ if shell_name == "zsh":
67
+ prelude.append("setopt aliases >/dev/null 2>&1 || true")
68
+ rc_files = ("$HOME/.zshenv", "$HOME/.zprofile", "$HOME/.zshrc")
69
+ elif shell_name == "bash":
70
+ prelude.append("shopt -s expand_aliases >/dev/null 2>&1 || true")
71
+ rc_files = ("$HOME/.bash_profile", "$HOME/.bash_login", "$HOME/.profile", "$HOME/.bashrc")
72
+ elif shell_name == "ksh":
73
+ rc_files = ("$HOME/.profile", "$HOME/.kshrc")
74
+ else:
75
+ rc_files = ()
76
+
77
+ lines = [f"GWS_COMMAND={shlex.quote(command)}", *prelude]
78
+ lines.extend(_source_if_readable(path) for path in rc_files)
79
+ lines.append("set +e")
80
+ lines.append('eval "$GWS_COMMAND"')
81
+ return "\n".join(lines)
82
+
83
+
84
+ def shell_invocation(command: str, interactive: bool = True) -> list[str]:
85
+ shell = shell_program()
86
+ name = Path(shell).name
87
+ if interactive and name in {"bash", "zsh", "ksh"}:
88
+ flag = "-fc" if name == "zsh" else "-c"
89
+ return [shell, flag, _posix_shell_script(command, name)]
90
+ if interactive and name == "fish":
91
+ return [shell, "-ic", command]
92
+ return [shell, "-lc", command]
93
+
94
+
95
+ def command_exists_in_shell(command: str, cwd: Path, interactive: bool = True) -> bool:
96
+ try:
97
+ parts = shlex.split(command)
98
+ except ValueError:
99
+ return True
100
+ if not parts:
101
+ return False
102
+ head = parts[0]
103
+ if head.startswith(":") or head in {"cd", "export", "alias", "unalias", "source", "."}:
104
+ return True
105
+ probe = f"type {shlex.quote(head)} >/dev/null 2>&1"
106
+ try:
107
+ proc = subprocess.run(
108
+ shell_invocation(probe, interactive=interactive),
109
+ cwd=str(cwd),
110
+ text=True,
111
+ stdout=subprocess.DEVNULL,
112
+ stderr=subprocess.DEVNULL,
113
+ timeout=3,
114
+ )
115
+ except Exception:
116
+ return True
117
+ return proc.returncode == 0
118
+
119
+
120
+ def aliases_for_repo(config: WorkspaceConfig, repo: Repo) -> dict[str, str]:
121
+ aliases: dict[str, str] = {}
122
+ aliases.update(BUILTIN_GIT_ALIASES)
123
+ aliases.update(git_aliases(repo))
124
+ aliases.update(config.aliases)
125
+ return aliases
126
+
127
+
128
+ def expand_git_alias(command: str, aliases: dict[str, str]) -> tuple[str, bool]:
129
+ command = normalize_git_command(command)
130
+ try:
131
+ parts = shlex.split(command)
132
+ except ValueError:
133
+ return command, False
134
+ if not parts:
135
+ return "", False
136
+ head = parts[0]
137
+ expansion = aliases.get(head)
138
+ if expansion is None:
139
+ return command, False
140
+ rest = " ".join(shlex.quote(part) for part in parts[1:])
141
+ expanded = f"{expansion} {rest}".strip()
142
+ return normalize_git_command(expanded), expanded != command
143
+
144
+
145
+ def resolve_command(
146
+ value: str,
147
+ repo: Repo,
148
+ config: WorkspaceConfig,
149
+ mode: ExecMode,
150
+ ) -> ResolvedCommand | None:
151
+ value = value.strip()
152
+ if not value:
153
+ return None
154
+
155
+ interactive = config.exec_settings.interactive_shell
156
+
157
+ if value.startswith("!"):
158
+ command = value[1:].strip()
159
+ if not command:
160
+ return None
161
+ return ResolvedCommand(value, shell_invocation(command, interactive), repo.path, True)
162
+
163
+ if mode == ExecMode.SHELL:
164
+ if command_exists_in_shell(value, repo.path, interactive):
165
+ return ResolvedCommand(value, shell_invocation(value, interactive), repo.path, True)
166
+ if not config.exec_settings.git_shortcuts:
167
+ return ResolvedCommand(value, shell_invocation(value, interactive), repo.path, True)
168
+
169
+ aliases = aliases_for_repo(config, repo)
170
+ command, expanded = expand_git_alias(value, aliases)
171
+ if not command:
172
+ return None
173
+ try:
174
+ git_args = shlex.split(command)
175
+ except ValueError as exc:
176
+ raise ValueError(f"解析失败:{exc}") from exc
177
+ return ResolvedCommand(
178
+ value,
179
+ ["git", "-C", str(repo.path), "--no-pager", *git_args],
180
+ config.root,
181
+ False,
182
+ command if expanded or value.startswith("git ") else None,
183
+ )
184
+
185
+
186
+ def process_env() -> dict[str, str]:
187
+ env = os.environ.copy()
188
+ term = os.environ.get("TERM") or "xterm-256color"
189
+ if term == "dumb":
190
+ term = "xterm-256color"
191
+ env.update(
192
+ {
193
+ "GIT_PAGER": "cat",
194
+ "PAGER": "cat",
195
+ "LESS": "FRX",
196
+ "GIT_TERMINAL_PROMPT": "0",
197
+ "GIT_WORKSPACE": "1",
198
+ "TERM": term,
199
+ }
200
+ )
201
+ return env
202
+
203
+
204
+ def terminate_process(proc: subprocess.Popen[str]) -> None:
205
+ try:
206
+ os.killpg(proc.pid, signal.SIGTERM)
207
+ except Exception:
208
+ proc.terminate()
git_workspace/git.py ADDED
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from .models import Repo, RepoState
7
+
8
+
9
+ def git(repo: Path, *args: str, timeout: float | None = 12) -> subprocess.CompletedProcess[str]:
10
+ try:
11
+ return subprocess.run(
12
+ ["git", "-C", str(repo), *args],
13
+ text=True,
14
+ capture_output=True,
15
+ timeout=timeout,
16
+ )
17
+ except subprocess.TimeoutExpired as exc:
18
+ return subprocess.CompletedProcess(
19
+ exc.cmd,
20
+ 124,
21
+ exc.stdout if isinstance(exc.stdout, str) else "",
22
+ exc.stderr if isinstance(exc.stderr, str) else "timeout",
23
+ )
24
+
25
+
26
+ def is_git_worktree(path: Path) -> bool:
27
+ result = git(path, "rev-parse", "--is-inside-work-tree")
28
+ return result.returncode == 0 and result.stdout.strip() == "true"
29
+
30
+
31
+ def git_toplevel(path: Path) -> Path | None:
32
+ result = git(path, "rev-parse", "--show-toplevel")
33
+ if result.returncode != 0:
34
+ return None
35
+ value = result.stdout.strip()
36
+ return Path(value).resolve() if value else None
37
+
38
+
39
+ def current_branch(repo: Path) -> str:
40
+ result = git(repo, "branch", "--show-current")
41
+ value = result.stdout.strip()
42
+ if value:
43
+ return value
44
+ result = git(repo, "rev-parse", "--short", "HEAD")
45
+ return f"detached:{result.stdout.strip() or '?'}"
46
+
47
+
48
+ def dirty_count(repo: Path) -> int:
49
+ result = git(repo, "status", "--porcelain=v1")
50
+ return len([line for line in result.stdout.splitlines() if line.strip()])
51
+
52
+
53
+ def upstream(repo: Path) -> str | None:
54
+ result = git(repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
55
+ if result.returncode != 0:
56
+ return None
57
+ value = result.stdout.strip()
58
+ return value if value and value != "@{u}" else None
59
+
60
+
61
+ def ahead_behind(repo: Path, upstream_ref: str) -> tuple[int, int] | tuple[None, None]:
62
+ result = git(repo, "rev-list", "--left-right", "--count", f"{upstream_ref}...HEAD")
63
+ if result.returncode != 0:
64
+ return None, None
65
+ parts = result.stdout.strip().split()
66
+ if len(parts) != 2:
67
+ return None, None
68
+ behind, ahead = parts
69
+ return int(ahead), int(behind)
70
+
71
+
72
+ def repo_state(repo: Repo) -> RepoState:
73
+ up = upstream(repo.path)
74
+ ahead: int | None = None
75
+ behind: int | None = None
76
+ if up:
77
+ ahead, behind = ahead_behind(repo.path, up)
78
+ return RepoState(
79
+ branch=current_branch(repo.path),
80
+ dirty=dirty_count(repo.path),
81
+ upstream=up,
82
+ ahead=ahead,
83
+ behind=behind,
84
+ )
85
+
86
+
87
+ def git_aliases(repo: Repo | None = None) -> dict[str, str]:
88
+ args = ["git"]
89
+ if repo is not None:
90
+ args.extend(["-C", str(repo.path)])
91
+ args.extend(["config", "--get-regexp", r"^alias\."])
92
+ proc = subprocess.run(
93
+ args,
94
+ text=True,
95
+ stdout=subprocess.PIPE,
96
+ stderr=subprocess.DEVNULL,
97
+ )
98
+ aliases: dict[str, str] = {}
99
+ if proc.returncode not in {0, 1}:
100
+ return aliases
101
+ for raw in proc.stdout.splitlines():
102
+ key, _, value = raw.partition(" ")
103
+ if not key.startswith("alias.") or not value.strip():
104
+ continue
105
+ aliases[key.removeprefix("alias.")] = value.strip()
106
+ return aliases