gitwise-cli 0.24.2__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.
- gitwise/__init__.py +11 -0
- gitwise/__main__.py +113 -0
- gitwise/_cli_completions.py +88 -0
- gitwise/_cli_dispatch.py +469 -0
- gitwise/_cli_introspection.py +275 -0
- gitwise/_cli_parser.py +345 -0
- gitwise/_cli_setup_agents.py +439 -0
- gitwise/_i18n_data.json +1934 -0
- gitwise/_paths.py +22 -0
- gitwise/_runtime_config.py +246 -0
- gitwise/audit.py +338 -0
- gitwise/branches.py +183 -0
- gitwise/clean.py +197 -0
- gitwise/commit.py +142 -0
- gitwise/conflicts.py +112 -0
- gitwise/context.py +163 -0
- gitwise/design.py +383 -0
- gitwise/diff.py +309 -0
- gitwise/doctor.py +116 -0
- gitwise/git.py +254 -0
- gitwise/health.py +345 -0
- gitwise/i18n.py +99 -0
- gitwise/log.py +329 -0
- gitwise/merge.py +193 -0
- gitwise/optimize.py +212 -0
- gitwise/output.py +652 -0
- gitwise/pick.py +102 -0
- gitwise/pr.py +543 -0
- gitwise/py.typed +0 -0
- gitwise/schema.py +49 -0
- gitwise/setup.py +551 -0
- gitwise/setup_agents/__init__.py +36 -0
- gitwise/setup_agents/adapters/__init__.py +17 -0
- gitwise/setup_agents/adapters/aider.py +5 -0
- gitwise/setup_agents/adapters/base.py +5 -0
- gitwise/setup_agents/adapters/codex.py +5 -0
- gitwise/setup_agents/adapters/continue_adapter.py +5 -0
- gitwise/setup_agents/adapters/cursor.py +5 -0
- gitwise/setup_agents/adapters/opencode.py +5 -0
- gitwise/setup_agents/adapters/pi.py +5 -0
- gitwise/setup_agents/exec.py +449 -0
- gitwise/setup_agents/format.py +164 -0
- gitwise/setup_agents/plan.py +254 -0
- gitwise/setup_agents/plan_gitfiles.py +167 -0
- gitwise/setup_agents/plan_skills.py +256 -0
- gitwise/setup_agents/providers/__init__.py +96 -0
- gitwise/setup_agents/providers/aider.py +11 -0
- gitwise/setup_agents/providers/base.py +79 -0
- gitwise/setup_agents/providers/claude.py +408 -0
- gitwise/setup_agents/providers/codex.py +11 -0
- gitwise/setup_agents/providers/continue_adapter.py +11 -0
- gitwise/setup_agents/providers/cursor.py +11 -0
- gitwise/setup_agents/providers/opencode.py +11 -0
- gitwise/setup_agents/providers/pi.py +11 -0
- gitwise/setup_agents/state.py +141 -0
- gitwise/setup_agents/types.py +48 -0
- gitwise/share/agents/skills/git-audit/SKILL.md +25 -0
- gitwise/share/agents/skills/git-clean/SKILL.md +22 -0
- gitwise/share/agents/skills/git-optimize/SKILL.md +21 -0
- gitwise/share/aider/CONVENTIONS.md.template +8 -0
- gitwise/share/aider/aider.conf.yml.template +4 -0
- gitwise/share/claude/CLAUDE.md.template +9 -0
- gitwise/share/claude/rules/gitwise.md +16 -0
- gitwise/share/claude/settings.json.template +47 -0
- gitwise/share/claude/skills/git-audit/SKILL.md +25 -0
- gitwise/share/claude/skills/git-clean/SKILL.md +22 -0
- gitwise/share/claude/skills/git-optimize/SKILL.md +21 -0
- gitwise/share/codex/agents/gitwise.toml.template +18 -0
- gitwise/share/continue/rules/gitwise.md.template +14 -0
- gitwise/share/cursor/rules/gitwise.mdc.template +16 -0
- gitwise/share/git-config-modern.txt +48 -0
- gitwise/share/hooks/commit-msg +22 -0
- gitwise/share/hooks/pre-commit +19 -0
- gitwise/share/opencode/agents/gitwise.md.template +14 -0
- gitwise/share/pi/skills/gitwise.md.template +14 -0
- gitwise/share/schemas/v1/input/audit.json +40 -0
- gitwise/share/schemas/v1/input/branches.json +51 -0
- gitwise/share/schemas/v1/input/clean.json +52 -0
- gitwise/share/schemas/v1/input/commands.json +36 -0
- gitwise/share/schemas/v1/input/commit.json +63 -0
- gitwise/share/schemas/v1/input/completions.json +51 -0
- gitwise/share/schemas/v1/input/conflicts.json +46 -0
- gitwise/share/schemas/v1/input/context.json +36 -0
- gitwise/share/schemas/v1/input/diff.json +56 -0
- gitwise/share/schemas/v1/input/doctor.json +36 -0
- gitwise/share/schemas/v1/input/health.json +36 -0
- gitwise/share/schemas/v1/input/log.json +71 -0
- gitwise/share/schemas/v1/input/merge.json +63 -0
- gitwise/share/schemas/v1/input/optimize.json +44 -0
- gitwise/share/schemas/v1/input/pick.json +63 -0
- gitwise/share/schemas/v1/input/pr.json +51 -0
- gitwise/share/schemas/v1/input/schema.json +48 -0
- gitwise/share/schemas/v1/input/setup-agents.json +108 -0
- gitwise/share/schemas/v1/input/setup.json +55 -0
- gitwise/share/schemas/v1/input/show.json +46 -0
- gitwise/share/schemas/v1/input/snapshot.json +36 -0
- gitwise/share/schemas/v1/input/stash.json +68 -0
- gitwise/share/schemas/v1/input/status.json +36 -0
- gitwise/share/schemas/v1/input/suggest.json +36 -0
- gitwise/share/schemas/v1/input/summarize.json +44 -0
- gitwise/share/schemas/v1/input/sync.json +55 -0
- gitwise/share/schemas/v1/input/tag.json +73 -0
- gitwise/share/schemas/v1/input/undo.json +60 -0
- gitwise/share/schemas/v1/input/update.json +40 -0
- gitwise/share/schemas/v1/input/worktree.json +50 -0
- gitwise/show.py +118 -0
- gitwise/snapshot.py +110 -0
- gitwise/stash.py +188 -0
- gitwise/status.py +93 -0
- gitwise/suggest.py +148 -0
- gitwise/summarize.py +202 -0
- gitwise/sync.py +257 -0
- gitwise/tag.py +252 -0
- gitwise/undo.py +145 -0
- gitwise/update.py +42 -0
- gitwise/utils/__init__.py +1 -0
- gitwise/utils/git_output.py +51 -0
- gitwise/utils/json_envelope.py +58 -0
- gitwise/utils/parsing.py +34 -0
- gitwise/worktree.py +182 -0
- gitwise_cli-0.24.2.dist-info/METADATA +151 -0
- gitwise_cli-0.24.2.dist-info/RECORD +125 -0
- gitwise_cli-0.24.2.dist-info/WHEEL +4 -0
- gitwise_cli-0.24.2.dist-info/entry_points.txt +2 -0
- gitwise_cli-0.24.2.dist-info/licenses/LICENSE +21 -0
gitwise/status.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""gitwise status — enhanced git status for humans and AI agents."""
|
|
2
|
+
|
|
3
|
+
from .git import current_branch, has_upstream, require_root
|
|
4
|
+
from .git import run as git_run
|
|
5
|
+
from .i18n import t
|
|
6
|
+
from .output import (
|
|
7
|
+
info,
|
|
8
|
+
ok,
|
|
9
|
+
print_blank,
|
|
10
|
+
print_bracket,
|
|
11
|
+
print_file_status,
|
|
12
|
+
print_header,
|
|
13
|
+
print_json,
|
|
14
|
+
)
|
|
15
|
+
from .utils.parsing import parse_two_ints
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_status(*, as_json: bool = False) -> int:
|
|
19
|
+
root, err = require_root()
|
|
20
|
+
if err:
|
|
21
|
+
return err
|
|
22
|
+
if root is None:
|
|
23
|
+
return 1
|
|
24
|
+
|
|
25
|
+
branch = current_branch(root) or t("detached_head")
|
|
26
|
+
|
|
27
|
+
status_r = git_run(["status", "--porcelain"], cwd=root, check=False)
|
|
28
|
+
status_lines = status_r.stdout.splitlines() if status_r.returncode == 0 else []
|
|
29
|
+
|
|
30
|
+
staged = [ln for ln in status_lines if ln and ln[0] not in (" ", "?")]
|
|
31
|
+
unstaged = [ln for ln in status_lines if ln and ln[1] not in (" ", "?")]
|
|
32
|
+
untracked = [ln for ln in status_lines if ln and ln.startswith("??")]
|
|
33
|
+
|
|
34
|
+
ahead = behind = 0
|
|
35
|
+
if has_upstream(root):
|
|
36
|
+
ab_r = git_run(
|
|
37
|
+
["rev-list", "--left-right", "--count", "HEAD...@{u}"],
|
|
38
|
+
cwd=root,
|
|
39
|
+
check=False,
|
|
40
|
+
)
|
|
41
|
+
if ab_r.returncode == 0:
|
|
42
|
+
parsed = parse_two_ints(ab_r.stdout)
|
|
43
|
+
if parsed is not None:
|
|
44
|
+
ahead, behind = parsed
|
|
45
|
+
|
|
46
|
+
if as_json:
|
|
47
|
+
print_json(
|
|
48
|
+
{
|
|
49
|
+
"v": 2,
|
|
50
|
+
"ok": True,
|
|
51
|
+
"branch": branch,
|
|
52
|
+
"has_upstream": has_upstream(root),
|
|
53
|
+
"ahead": ahead,
|
|
54
|
+
"behind": behind,
|
|
55
|
+
"staged": len(staged),
|
|
56
|
+
"unstaged": len(unstaged),
|
|
57
|
+
"untracked": len(untracked),
|
|
58
|
+
"files": [ln[3:] for ln in status_lines],
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
print_header(t("branch_label", branch=branch))
|
|
64
|
+
if ahead or behind:
|
|
65
|
+
print_bracket(t("status_ahead_label"), str(ahead))
|
|
66
|
+
print_bracket(t("status_behind_label"), str(behind))
|
|
67
|
+
|
|
68
|
+
if not status_lines:
|
|
69
|
+
print_blank()
|
|
70
|
+
ok(t("working_tree_clean"))
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
if staged:
|
|
74
|
+
print_blank()
|
|
75
|
+
print_header(f"{t('status_staged_label')} ({len(staged)}):")
|
|
76
|
+
for line in staged:
|
|
77
|
+
print_file_status(line[:2], line[3:])
|
|
78
|
+
|
|
79
|
+
if unstaged:
|
|
80
|
+
print_blank()
|
|
81
|
+
print_header(f"{t('status_unstaged_label')} ({len(unstaged)}):")
|
|
82
|
+
for line in unstaged:
|
|
83
|
+
print_file_status(line[:2], line[3:])
|
|
84
|
+
|
|
85
|
+
if untracked:
|
|
86
|
+
print_blank()
|
|
87
|
+
print_header(f"{t('status_untracked_label')} ({len(untracked)}):")
|
|
88
|
+
for line in untracked[:10]:
|
|
89
|
+
print_file_status("??", line[3:])
|
|
90
|
+
if len(untracked) > 10:
|
|
91
|
+
info(f" {t('status_more_files', count=str(len(untracked) - 10))}")
|
|
92
|
+
|
|
93
|
+
return 0
|
gitwise/suggest.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""gitwise suggest — heuristic commit message from staged diff."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from .git import require_root
|
|
6
|
+
from .git import run as git_run
|
|
7
|
+
from .i18n import t
|
|
8
|
+
from .output import error, print_bracket, print_file_status, print_header, print_json
|
|
9
|
+
from .utils.json_envelope import error_envelope, ok_envelope
|
|
10
|
+
from .utils.parsing import stripped_non_empty_lines
|
|
11
|
+
|
|
12
|
+
_TYPE_MAP: list[tuple[str, str]] = [
|
|
13
|
+
(r"/test", "test"),
|
|
14
|
+
(r"test_", "test"),
|
|
15
|
+
(r"_test\.", "test"),
|
|
16
|
+
(r"README", "docs"),
|
|
17
|
+
(r"CHANGELOG", "docs"),
|
|
18
|
+
(r"\.md$", "docs"),
|
|
19
|
+
(r"\.txt$", "docs"),
|
|
20
|
+
(r"Dockerfile", "build"),
|
|
21
|
+
(r"docker-compose", "build"),
|
|
22
|
+
(r"\.ya?ml$", "ci"),
|
|
23
|
+
(r"\.toml$", "build"),
|
|
24
|
+
(r"\.cfg$", "build"),
|
|
25
|
+
(r"\.json$", "chore"),
|
|
26
|
+
(r"\.lock$", "chore"),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _infer_type(files: list[str]) -> str:
|
|
31
|
+
for pattern, commit_type in _TYPE_MAP:
|
|
32
|
+
for f in files:
|
|
33
|
+
if re.search(pattern, f):
|
|
34
|
+
return commit_type
|
|
35
|
+
return "feat"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _infer_scope(files: list[str]) -> str | None:
|
|
39
|
+
dirs: set[str] = set()
|
|
40
|
+
for f in files:
|
|
41
|
+
parts = f.split("/")
|
|
42
|
+
if len(parts) > 1:
|
|
43
|
+
dirs.add(parts[0])
|
|
44
|
+
if len(dirs) == 1:
|
|
45
|
+
return dirs.pop()
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _build_message(staged_files: list[str], additions: int, deletions: int) -> str:
|
|
50
|
+
commit_type = _infer_type(staged_files)
|
|
51
|
+
scope = _infer_scope(staged_files)
|
|
52
|
+
scope_str = f"({scope})" if scope else ""
|
|
53
|
+
if len(staged_files) == 1:
|
|
54
|
+
filename = staged_files[0].rsplit("/", 1)[-1]
|
|
55
|
+
return f"{commit_type}{scope_str}: {t('suggest_update_file', filename=filename)}"
|
|
56
|
+
return f"{commit_type}{scope_str}: {t('suggest_update_files', count=str(len(staged_files)))}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _staged_with_status(root) -> list[tuple[str, str]]:
|
|
60
|
+
r = git_run(["diff", "--cached", "--name-status"], cwd=root, check=False)
|
|
61
|
+
if r.returncode != 0:
|
|
62
|
+
return []
|
|
63
|
+
pairs: list[tuple[str, str]] = []
|
|
64
|
+
for line in r.stdout.splitlines():
|
|
65
|
+
parts = line.split("\t")
|
|
66
|
+
if len(parts) < 2:
|
|
67
|
+
continue
|
|
68
|
+
status = parts[0][:1].upper()
|
|
69
|
+
path = parts[-1].strip()
|
|
70
|
+
if path:
|
|
71
|
+
pairs.append((status, path))
|
|
72
|
+
return pairs
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _collect_staged_files(root) -> tuple[list[str], dict[str, str]]:
|
|
76
|
+
r = git_run(["diff", "--cached", "--name-only"], cwd=root, check=False)
|
|
77
|
+
if r.returncode != 0:
|
|
78
|
+
raise RuntimeError("staged_diff_failed")
|
|
79
|
+
staged_files = stripped_non_empty_lines(r.stdout)
|
|
80
|
+
staged_pairs = _staged_with_status(root)
|
|
81
|
+
staged_map = {path: status for status, path in staged_pairs}
|
|
82
|
+
return staged_files, staged_map
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _numstat_totals(root) -> tuple[int, int]:
|
|
86
|
+
stat = git_run(["diff", "--cached", "--numstat"], cwd=root, check=False)
|
|
87
|
+
additions = deletions = 0
|
|
88
|
+
if stat.returncode != 0:
|
|
89
|
+
return additions, deletions
|
|
90
|
+
for line in stat.stdout.splitlines():
|
|
91
|
+
parts = line.split()
|
|
92
|
+
if len(parts) < 2:
|
|
93
|
+
continue
|
|
94
|
+
added = parts[0]
|
|
95
|
+
removed = parts[1]
|
|
96
|
+
if added != "-" and added.isdigit():
|
|
97
|
+
additions += int(added)
|
|
98
|
+
if removed != "-" and removed.isdigit():
|
|
99
|
+
deletions += int(removed)
|
|
100
|
+
return additions, deletions
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _print_suggest_human(
|
|
104
|
+
message: str, staged_files: list[str], staged_map: dict[str, str]
|
|
105
|
+
) -> None:
|
|
106
|
+
print_header(t("suggest_message", message=message))
|
|
107
|
+
print_bracket(t("suggest_type", type=_infer_type(staged_files)))
|
|
108
|
+
for file_path in staged_files:
|
|
109
|
+
print_file_status(staged_map.get(file_path, "M"), file_path)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def run_suggest(*, as_json: bool = False) -> int:
|
|
113
|
+
root, err = require_root()
|
|
114
|
+
if err:
|
|
115
|
+
return err
|
|
116
|
+
if root is None:
|
|
117
|
+
return 1
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
staged_files, staged_map = _collect_staged_files(root)
|
|
121
|
+
except RuntimeError:
|
|
122
|
+
error(t("suggest_diff_failed"))
|
|
123
|
+
return 1
|
|
124
|
+
|
|
125
|
+
if not staged_files:
|
|
126
|
+
if as_json:
|
|
127
|
+
print_json(error_envelope(error=t("suggest_no_staged")))
|
|
128
|
+
return 1
|
|
129
|
+
error(t("suggest_no_staged"))
|
|
130
|
+
return 1
|
|
131
|
+
|
|
132
|
+
additions, deletions = _numstat_totals(root)
|
|
133
|
+
|
|
134
|
+
message = _build_message(staged_files, additions, deletions)
|
|
135
|
+
|
|
136
|
+
if as_json:
|
|
137
|
+
print_json(
|
|
138
|
+
ok_envelope(
|
|
139
|
+
message=message,
|
|
140
|
+
files=staged_files,
|
|
141
|
+
additions=additions,
|
|
142
|
+
deletions=deletions,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
_print_suggest_human(message, staged_files, staged_map)
|
|
148
|
+
return 0
|
gitwise/summarize.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Compact git status + log for Claude Code context reduction."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
from ._runtime_config import get_runtime_config
|
|
6
|
+
from .git import require_root
|
|
7
|
+
from .git import run as git_run
|
|
8
|
+
from .i18n import t
|
|
9
|
+
from .output import (
|
|
10
|
+
bat_pipe,
|
|
11
|
+
debug,
|
|
12
|
+
ok,
|
|
13
|
+
print_blank,
|
|
14
|
+
print_bullet,
|
|
15
|
+
print_commit_line,
|
|
16
|
+
print_file_status,
|
|
17
|
+
print_header,
|
|
18
|
+
print_json,
|
|
19
|
+
warn,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_MAX_STATUS_LINES = 12
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _status_code(index: str, worktree: str) -> str:
|
|
26
|
+
if index == "?" and worktree == "?":
|
|
27
|
+
return "??"
|
|
28
|
+
if worktree not in {" ", "?"}:
|
|
29
|
+
return worktree
|
|
30
|
+
if index not in {" ", "?"}:
|
|
31
|
+
return index
|
|
32
|
+
return "--"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_status_entries(status_lines: list[str]) -> list[dict[str, str]]:
|
|
36
|
+
entries: list[dict[str, str]] = []
|
|
37
|
+
for line in status_lines:
|
|
38
|
+
if len(line) < 4:
|
|
39
|
+
continue
|
|
40
|
+
index = line[0]
|
|
41
|
+
worktree = line[1]
|
|
42
|
+
path_part = line[3:].strip()
|
|
43
|
+
if not path_part:
|
|
44
|
+
continue
|
|
45
|
+
entry: dict[str, str] = {
|
|
46
|
+
"index": index,
|
|
47
|
+
"worktree": worktree,
|
|
48
|
+
"status": _status_code(index, worktree),
|
|
49
|
+
}
|
|
50
|
+
if " -> " in path_part:
|
|
51
|
+
old_path, new_path = path_part.split(" -> ", 1)
|
|
52
|
+
entry["old_path"] = old_path
|
|
53
|
+
entry["path"] = new_path
|
|
54
|
+
else:
|
|
55
|
+
entry["path"] = path_part
|
|
56
|
+
entries.append(entry)
|
|
57
|
+
return entries
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_log_entries(log_lines: list[str]) -> list[dict[str, str]]:
|
|
61
|
+
entries: list[dict[str, str]] = []
|
|
62
|
+
for line in log_lines:
|
|
63
|
+
parts = line.split(" ", 1)
|
|
64
|
+
if len(parts) != 2:
|
|
65
|
+
continue
|
|
66
|
+
entries.append({"short_hash": parts[0], "subject": parts[1]})
|
|
67
|
+
return entries
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_changed_entries(changed_files: list[str]) -> list[dict[str, str]]:
|
|
71
|
+
entries: list[dict[str, str]] = []
|
|
72
|
+
for line in changed_files:
|
|
73
|
+
parts = line.split("\t")
|
|
74
|
+
if len(parts) < 2:
|
|
75
|
+
continue
|
|
76
|
+
status = parts[0].strip()
|
|
77
|
+
code = status[:1].upper() if status else ""
|
|
78
|
+
entry: dict[str, str] = {"status": status}
|
|
79
|
+
if code and code != status:
|
|
80
|
+
entry["code"] = code
|
|
81
|
+
if code in {"R", "C"} and len(parts) >= 3:
|
|
82
|
+
entry["old_path"] = parts[1].strip()
|
|
83
|
+
entry["path"] = parts[2].strip()
|
|
84
|
+
else:
|
|
85
|
+
entry["path"] = parts[-1].strip()
|
|
86
|
+
if len(status) > 1 and status[1:].isdigit():
|
|
87
|
+
entry["score"] = status[1:]
|
|
88
|
+
if entry["path"]:
|
|
89
|
+
entries.append(entry)
|
|
90
|
+
return entries
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def run_summarize(*, as_json: bool = False, diff: bool = False, max_commits: int = 10) -> int:
|
|
94
|
+
root, err = require_root()
|
|
95
|
+
if err:
|
|
96
|
+
return err
|
|
97
|
+
if root is None:
|
|
98
|
+
return 1
|
|
99
|
+
cwd = root
|
|
100
|
+
|
|
101
|
+
branch_r = git_run(["branch", "--show-current"], cwd=cwd, check=False)
|
|
102
|
+
branch = branch_r.stdout.strip() if branch_r.returncode == 0 else "detached-HEAD"
|
|
103
|
+
|
|
104
|
+
status_r = git_run(["status", "--short"], cwd=cwd, check=False)
|
|
105
|
+
status_lines = status_r.stdout.splitlines() if status_r.returncode == 0 else []
|
|
106
|
+
|
|
107
|
+
log_r = git_run(
|
|
108
|
+
["--no-pager", "log", "--oneline", f"-n{max_commits}"],
|
|
109
|
+
cwd=cwd,
|
|
110
|
+
check=False,
|
|
111
|
+
)
|
|
112
|
+
log_lines = log_r.stdout.splitlines() if log_r.returncode == 0 else []
|
|
113
|
+
|
|
114
|
+
shortstat_r = git_run(["--no-pager", "diff", "--shortstat"], cwd=cwd, check=False)
|
|
115
|
+
shortstat = shortstat_r.stdout.strip() if shortstat_r.returncode == 0 else ""
|
|
116
|
+
|
|
117
|
+
changed_r = git_run(["--no-pager", "diff", "--name-status", "HEAD"], cwd=cwd, check=False)
|
|
118
|
+
changed_files = changed_r.stdout.splitlines() if changed_r.returncode == 0 else []
|
|
119
|
+
|
|
120
|
+
status_entries = _parse_status_entries(status_lines)
|
|
121
|
+
status_map = {entry["path"]: entry["status"] for entry in status_entries}
|
|
122
|
+
log_entries = _parse_log_entries(log_lines)
|
|
123
|
+
log_map = {entry["short_hash"]: entry["subject"] for entry in log_entries}
|
|
124
|
+
changed_entries = _parse_changed_entries(changed_files)
|
|
125
|
+
|
|
126
|
+
if as_json:
|
|
127
|
+
import json
|
|
128
|
+
|
|
129
|
+
result: dict = {
|
|
130
|
+
"v": 3,
|
|
131
|
+
"ok": True,
|
|
132
|
+
"branch": branch,
|
|
133
|
+
"status": status_map,
|
|
134
|
+
"status_count": len(status_entries),
|
|
135
|
+
"log": log_map,
|
|
136
|
+
"log_count": len(log_entries),
|
|
137
|
+
"shortstat": shortstat,
|
|
138
|
+
"changed_files": changed_entries,
|
|
139
|
+
"changed_count": len(changed_entries),
|
|
140
|
+
}
|
|
141
|
+
if diff:
|
|
142
|
+
diff_r = git_run(["--no-pager", "diff"], cwd=cwd, check=False)
|
|
143
|
+
result["diff"] = diff_r.stdout if diff_r.returncode == 0 else ""
|
|
144
|
+
print_json(result)
|
|
145
|
+
output_size = len(json.dumps(result))
|
|
146
|
+
if output_size > 8192:
|
|
147
|
+
warn(t("output_exceeds_8kb", size=str(output_size)))
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
print_header(t("branch_label", branch=branch))
|
|
151
|
+
print_blank()
|
|
152
|
+
|
|
153
|
+
if status_lines:
|
|
154
|
+
print_header(t("modified_files_status", count=str(len(status_lines))))
|
|
155
|
+
for line in status_lines[:_MAX_STATUS_LINES]:
|
|
156
|
+
print_file_status(line[:2], line[3:])
|
|
157
|
+
if len(status_lines) > _MAX_STATUS_LINES:
|
|
158
|
+
print_bullet(
|
|
159
|
+
t("status_more_files", count=str(len(status_lines) - _MAX_STATUS_LINES)),
|
|
160
|
+
icon="+",
|
|
161
|
+
accent=False,
|
|
162
|
+
)
|
|
163
|
+
print_blank()
|
|
164
|
+
else:
|
|
165
|
+
ok(t("working_tree_clean"))
|
|
166
|
+
print_blank()
|
|
167
|
+
|
|
168
|
+
if log_lines:
|
|
169
|
+
print_header(t("last_commits", count=str(len(log_lines))))
|
|
170
|
+
for line in log_lines:
|
|
171
|
+
print_commit_line(line)
|
|
172
|
+
print_blank()
|
|
173
|
+
else:
|
|
174
|
+
print_bullet(t("no_commits_yet"), icon="-", accent=False)
|
|
175
|
+
print_blank()
|
|
176
|
+
|
|
177
|
+
if shortstat:
|
|
178
|
+
print_header(t("diff_prefix", stat=shortstat))
|
|
179
|
+
|
|
180
|
+
if diff:
|
|
181
|
+
diff_r = git_run(["--no-pager", "diff"], cwd=cwd, check=False)
|
|
182
|
+
if diff_r.stdout:
|
|
183
|
+
cfg = get_runtime_config()
|
|
184
|
+
if cfg.has_delta and cfg.is_tty:
|
|
185
|
+
debug(t("using_delta"))
|
|
186
|
+
try:
|
|
187
|
+
subprocess.run(
|
|
188
|
+
["delta"],
|
|
189
|
+
input=diff_r.stdout,
|
|
190
|
+
text=True,
|
|
191
|
+
timeout=120,
|
|
192
|
+
check=False,
|
|
193
|
+
)
|
|
194
|
+
except subprocess.TimeoutExpired:
|
|
195
|
+
bat_pipe(diff_r.stdout, language="diff")
|
|
196
|
+
except (FileNotFoundError, OSError):
|
|
197
|
+
bat_pipe(diff_r.stdout, language="diff")
|
|
198
|
+
else:
|
|
199
|
+
stat_r = git_run(["--no-pager", "diff", "--stat"], cwd=cwd, check=False)
|
|
200
|
+
bat_pipe(stat_r.stdout, language="diff")
|
|
201
|
+
|
|
202
|
+
return 0
|
gitwise/sync.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""gitwise sync — remote fetch, safe pull/push, ahead/behind reporting."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .git import PROTECTED_BRANCHES, current_branch, require_root
|
|
6
|
+
from .git import run as git_run
|
|
7
|
+
from .i18n import t
|
|
8
|
+
from .output import (
|
|
9
|
+
debug,
|
|
10
|
+
error,
|
|
11
|
+
print_bracket,
|
|
12
|
+
print_dim,
|
|
13
|
+
print_header,
|
|
14
|
+
print_json,
|
|
15
|
+
status,
|
|
16
|
+
)
|
|
17
|
+
from .utils.json_envelope import error_envelope, ok_envelope
|
|
18
|
+
from .utils.parsing import parse_two_ints, stripped_non_empty_lines
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _ahead_behind(cwd: Path) -> dict[str, int]:
|
|
22
|
+
branch = current_branch(cwd=cwd)
|
|
23
|
+
if not branch:
|
|
24
|
+
return {"ahead": 0, "behind": 0}
|
|
25
|
+
r = git_run(
|
|
26
|
+
["rev-list", "--left-right", "--count", branch + "@{u}...HEAD"], cwd=cwd, check=False
|
|
27
|
+
)
|
|
28
|
+
if r.returncode != 0:
|
|
29
|
+
debug(f"ahead_behind failed: {r.stderr.strip()}")
|
|
30
|
+
return {"ahead": 0, "behind": 0}
|
|
31
|
+
parsed = parse_two_ints(r.stdout)
|
|
32
|
+
if parsed is not None:
|
|
33
|
+
behind, ahead = parsed
|
|
34
|
+
return {"behind": behind, "ahead": ahead}
|
|
35
|
+
debug(f"ahead_behind parse failed: {r.stdout.strip()!r}")
|
|
36
|
+
return {"ahead": 0, "behind": 0}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _unpushed_commits(cwd) -> list[str]:
|
|
40
|
+
branch = current_branch(cwd=cwd)
|
|
41
|
+
if not branch:
|
|
42
|
+
return []
|
|
43
|
+
r = git_run(["log", "--oneline", branch + "@{u}..HEAD"], cwd=cwd, check=False)
|
|
44
|
+
if r.returncode != 0:
|
|
45
|
+
debug(f"unpushed_commits failed: {r.stderr.strip()}")
|
|
46
|
+
return []
|
|
47
|
+
return stripped_non_empty_lines(r.stdout)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sync_dry_run_payload(
|
|
51
|
+
*,
|
|
52
|
+
branch: str,
|
|
53
|
+
pull: bool,
|
|
54
|
+
push: bool,
|
|
55
|
+
remote: str | None,
|
|
56
|
+
root: Path,
|
|
57
|
+
) -> dict[str, object]:
|
|
58
|
+
ab = _ahead_behind(root)
|
|
59
|
+
unpushed = _unpushed_commits(root)
|
|
60
|
+
return {
|
|
61
|
+
"branch": branch,
|
|
62
|
+
"ahead": ab["ahead"],
|
|
63
|
+
"behind": ab["behind"],
|
|
64
|
+
"unpushed": unpushed,
|
|
65
|
+
"actions": _planned_actions(pull, push, ab, unpushed, remote),
|
|
66
|
+
"dry_run": True,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _print_sync_dry_run_human(*, pull: bool, push: bool, root: Path, remote: str | None) -> None:
|
|
71
|
+
ab = _ahead_behind(root)
|
|
72
|
+
unpushed = _unpushed_commits(root)
|
|
73
|
+
print_header(t("sync_dry_run_title"))
|
|
74
|
+
for action in _planned_actions(pull, push, ab, unpushed, remote):
|
|
75
|
+
print_bracket(action)
|
|
76
|
+
print_dim(t("dry_run_no_exec"))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _sync_fetch(*, root: Path, remote: str | None, as_json: bool) -> int:
|
|
80
|
+
with status(t("status_sync_fetch")):
|
|
81
|
+
result = git_run(
|
|
82
|
+
["fetch", "--prune"] + ([remote] if remote else ["--all"]), cwd=root, check=False
|
|
83
|
+
)
|
|
84
|
+
if result.returncode == 0:
|
|
85
|
+
return 0
|
|
86
|
+
if as_json:
|
|
87
|
+
print_json(
|
|
88
|
+
error_envelope(
|
|
89
|
+
error=t("sync_fetch_failed", error=result.stderr.strip()),
|
|
90
|
+
code="sync_fetch_failed",
|
|
91
|
+
hint=t("sync_hint"),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
error(t("sync_fetch_failed", error=result.stderr.strip()))
|
|
96
|
+
return 1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_SYNC_PULL_DIVERGED_COMMANDS = (
|
|
100
|
+
"gitwise sync --dry-run --json",
|
|
101
|
+
"git pull --rebase",
|
|
102
|
+
"git pull --no-rebase",
|
|
103
|
+
"gitwise sync --push",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _sync_pull(*, root: Path, as_json: bool) -> int:
|
|
108
|
+
with status(t("status_sync_pull")):
|
|
109
|
+
result = git_run(["pull", "--ff-only"], cwd=root, check=False)
|
|
110
|
+
if result.returncode == 0:
|
|
111
|
+
return 0
|
|
112
|
+
hint = t("sync_pull_diverged_hint")
|
|
113
|
+
if as_json:
|
|
114
|
+
print_json(
|
|
115
|
+
error_envelope(
|
|
116
|
+
error=t("sync_pull_diverged"),
|
|
117
|
+
code="sync_pull_diverged",
|
|
118
|
+
hint=hint,
|
|
119
|
+
suggested_commands=list(_SYNC_PULL_DIVERGED_COMMANDS),
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
error(t("sync_pull_diverged"), hint=hint)
|
|
124
|
+
return 1
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _sync_push(*, root: Path, branch: str, as_json: bool) -> int:
|
|
128
|
+
if branch in PROTECTED_BRANCHES:
|
|
129
|
+
if as_json:
|
|
130
|
+
print_json(
|
|
131
|
+
error_envelope(
|
|
132
|
+
error=t("sync_push_protected", branch=branch),
|
|
133
|
+
code="sync_push_protected",
|
|
134
|
+
hint=t("sync_push_protected_hint"),
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
else:
|
|
138
|
+
error(t("sync_push_protected", branch=branch))
|
|
139
|
+
return 1
|
|
140
|
+
with status(t("status_sync_push")):
|
|
141
|
+
result = git_run(["push"], cwd=root, check=False)
|
|
142
|
+
if result.returncode == 0:
|
|
143
|
+
return 0
|
|
144
|
+
if as_json:
|
|
145
|
+
print_json(
|
|
146
|
+
error_envelope(
|
|
147
|
+
error=t("sync_push_failed", error=result.stderr.strip()),
|
|
148
|
+
code="sync_push_failed",
|
|
149
|
+
hint=t("sync_hint"),
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
error(t("sync_push_failed", error=result.stderr.strip()))
|
|
154
|
+
return 1
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _print_sync_complete_human(*, branch: str, ahead: int, behind: int) -> None:
|
|
158
|
+
print_header(t("sync_complete_title"))
|
|
159
|
+
print_bracket(branch, t("sync_status", ahead=str(ahead), behind=str(behind)))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _sync_final_payload(*, branch: str, root: Path) -> dict[str, object]:
|
|
163
|
+
ab = _ahead_behind(root)
|
|
164
|
+
return {
|
|
165
|
+
"branch": branch,
|
|
166
|
+
"ahead": ab["ahead"],
|
|
167
|
+
"behind": ab["behind"],
|
|
168
|
+
"unpushed": _unpushed_commits(root),
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _report_sync_final(*, as_json: bool, branch: str, root: Path) -> int:
|
|
173
|
+
payload = _sync_final_payload(branch=branch, root=root)
|
|
174
|
+
if as_json:
|
|
175
|
+
print_json(ok_envelope(payload=payload))
|
|
176
|
+
return 0
|
|
177
|
+
ahead = payload["ahead"]
|
|
178
|
+
behind = payload["behind"]
|
|
179
|
+
_print_sync_complete_human(
|
|
180
|
+
branch=branch,
|
|
181
|
+
ahead=ahead if isinstance(ahead, int) else 0,
|
|
182
|
+
behind=behind if isinstance(behind, int) else 0,
|
|
183
|
+
)
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _run_sync_dry_run(
|
|
188
|
+
*, as_json: bool, branch: str, pull: bool, push: bool, remote: str | None, root: Path
|
|
189
|
+
) -> int:
|
|
190
|
+
if as_json:
|
|
191
|
+
print_json(
|
|
192
|
+
ok_envelope(
|
|
193
|
+
payload=_sync_dry_run_payload(
|
|
194
|
+
branch=branch, pull=pull, push=push, remote=remote, root=root
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
return 0
|
|
199
|
+
_print_sync_dry_run_human(pull=pull, push=push, root=root, remote=remote)
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def run_sync(
|
|
204
|
+
*,
|
|
205
|
+
pull: bool = False,
|
|
206
|
+
push: bool = False,
|
|
207
|
+
remote: str | None = None,
|
|
208
|
+
dry_run: bool = False,
|
|
209
|
+
as_json: bool = False,
|
|
210
|
+
) -> int:
|
|
211
|
+
root, err = require_root()
|
|
212
|
+
if err:
|
|
213
|
+
return err
|
|
214
|
+
if root is None:
|
|
215
|
+
return 1
|
|
216
|
+
|
|
217
|
+
branch = current_branch(cwd=root) or ""
|
|
218
|
+
|
|
219
|
+
if dry_run:
|
|
220
|
+
return _run_sync_dry_run(
|
|
221
|
+
as_json=as_json,
|
|
222
|
+
branch=branch,
|
|
223
|
+
pull=pull,
|
|
224
|
+
push=push,
|
|
225
|
+
remote=remote,
|
|
226
|
+
root=root,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
fetch_rc = _sync_fetch(root=root, remote=remote, as_json=as_json)
|
|
230
|
+
if fetch_rc != 0:
|
|
231
|
+
return fetch_rc
|
|
232
|
+
|
|
233
|
+
if pull:
|
|
234
|
+
pull_rc = _sync_pull(root=root, as_json=as_json)
|
|
235
|
+
if pull_rc != 0:
|
|
236
|
+
return pull_rc
|
|
237
|
+
|
|
238
|
+
if push:
|
|
239
|
+
push_rc = _sync_push(root=root, branch=branch, as_json=as_json)
|
|
240
|
+
if push_rc != 0:
|
|
241
|
+
return push_rc
|
|
242
|
+
|
|
243
|
+
return _report_sync_final(as_json=as_json, branch=branch, root=root)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _planned_actions(
|
|
247
|
+
pull: bool, push: bool, ab: dict, unpushed: list, remote: str | None = None
|
|
248
|
+
) -> list[str]:
|
|
249
|
+
fetch_cmd = (
|
|
250
|
+
t("sync_action_fetch_remote", remote=remote) if remote else t("sync_action_fetch_all")
|
|
251
|
+
)
|
|
252
|
+
actions = [fetch_cmd]
|
|
253
|
+
if pull and ab["behind"] > 0:
|
|
254
|
+
actions.append(t("sync_pull_ff"))
|
|
255
|
+
if push and unpushed:
|
|
256
|
+
actions.append(t("sync_push_commits", count=str(len(unpushed))))
|
|
257
|
+
return actions
|