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.
- git_workspace/__init__.py +6 -0
- git_workspace/__main__.py +7 -0
- git_workspace/cli.py +168 -0
- git_workspace/config.py +128 -0
- git_workspace/executor.py +208 -0
- git_workspace/git.py +106 -0
- git_workspace/models.py +81 -0
- git_workspace/output.py +55 -0
- git_workspace/planner.py +45 -0
- git_workspace/styles.py +45 -0
- git_workspace/terminal.py +246 -0
- git_workspace/tui.py +1000 -0
- git_workspace/workspace.py +51 -0
- git_workspace_tui-0.1.0.dist-info/METADATA +265 -0
- git_workspace_tui-0.1.0.dist-info/RECORD +18 -0
- git_workspace_tui-0.1.0.dist-info/WHEEL +4 -0
- git_workspace_tui-0.1.0.dist-info/entry_points.txt +3 -0
- git_workspace_tui-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
git_workspace/config.py
ADDED
|
@@ -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
|