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.
Files changed (125) hide show
  1. gitwise/__init__.py +11 -0
  2. gitwise/__main__.py +113 -0
  3. gitwise/_cli_completions.py +88 -0
  4. gitwise/_cli_dispatch.py +469 -0
  5. gitwise/_cli_introspection.py +275 -0
  6. gitwise/_cli_parser.py +345 -0
  7. gitwise/_cli_setup_agents.py +439 -0
  8. gitwise/_i18n_data.json +1934 -0
  9. gitwise/_paths.py +22 -0
  10. gitwise/_runtime_config.py +246 -0
  11. gitwise/audit.py +338 -0
  12. gitwise/branches.py +183 -0
  13. gitwise/clean.py +197 -0
  14. gitwise/commit.py +142 -0
  15. gitwise/conflicts.py +112 -0
  16. gitwise/context.py +163 -0
  17. gitwise/design.py +383 -0
  18. gitwise/diff.py +309 -0
  19. gitwise/doctor.py +116 -0
  20. gitwise/git.py +254 -0
  21. gitwise/health.py +345 -0
  22. gitwise/i18n.py +99 -0
  23. gitwise/log.py +329 -0
  24. gitwise/merge.py +193 -0
  25. gitwise/optimize.py +212 -0
  26. gitwise/output.py +652 -0
  27. gitwise/pick.py +102 -0
  28. gitwise/pr.py +543 -0
  29. gitwise/py.typed +0 -0
  30. gitwise/schema.py +49 -0
  31. gitwise/setup.py +551 -0
  32. gitwise/setup_agents/__init__.py +36 -0
  33. gitwise/setup_agents/adapters/__init__.py +17 -0
  34. gitwise/setup_agents/adapters/aider.py +5 -0
  35. gitwise/setup_agents/adapters/base.py +5 -0
  36. gitwise/setup_agents/adapters/codex.py +5 -0
  37. gitwise/setup_agents/adapters/continue_adapter.py +5 -0
  38. gitwise/setup_agents/adapters/cursor.py +5 -0
  39. gitwise/setup_agents/adapters/opencode.py +5 -0
  40. gitwise/setup_agents/adapters/pi.py +5 -0
  41. gitwise/setup_agents/exec.py +449 -0
  42. gitwise/setup_agents/format.py +164 -0
  43. gitwise/setup_agents/plan.py +254 -0
  44. gitwise/setup_agents/plan_gitfiles.py +167 -0
  45. gitwise/setup_agents/plan_skills.py +256 -0
  46. gitwise/setup_agents/providers/__init__.py +96 -0
  47. gitwise/setup_agents/providers/aider.py +11 -0
  48. gitwise/setup_agents/providers/base.py +79 -0
  49. gitwise/setup_agents/providers/claude.py +408 -0
  50. gitwise/setup_agents/providers/codex.py +11 -0
  51. gitwise/setup_agents/providers/continue_adapter.py +11 -0
  52. gitwise/setup_agents/providers/cursor.py +11 -0
  53. gitwise/setup_agents/providers/opencode.py +11 -0
  54. gitwise/setup_agents/providers/pi.py +11 -0
  55. gitwise/setup_agents/state.py +141 -0
  56. gitwise/setup_agents/types.py +48 -0
  57. gitwise/share/agents/skills/git-audit/SKILL.md +25 -0
  58. gitwise/share/agents/skills/git-clean/SKILL.md +22 -0
  59. gitwise/share/agents/skills/git-optimize/SKILL.md +21 -0
  60. gitwise/share/aider/CONVENTIONS.md.template +8 -0
  61. gitwise/share/aider/aider.conf.yml.template +4 -0
  62. gitwise/share/claude/CLAUDE.md.template +9 -0
  63. gitwise/share/claude/rules/gitwise.md +16 -0
  64. gitwise/share/claude/settings.json.template +47 -0
  65. gitwise/share/claude/skills/git-audit/SKILL.md +25 -0
  66. gitwise/share/claude/skills/git-clean/SKILL.md +22 -0
  67. gitwise/share/claude/skills/git-optimize/SKILL.md +21 -0
  68. gitwise/share/codex/agents/gitwise.toml.template +18 -0
  69. gitwise/share/continue/rules/gitwise.md.template +14 -0
  70. gitwise/share/cursor/rules/gitwise.mdc.template +16 -0
  71. gitwise/share/git-config-modern.txt +48 -0
  72. gitwise/share/hooks/commit-msg +22 -0
  73. gitwise/share/hooks/pre-commit +19 -0
  74. gitwise/share/opencode/agents/gitwise.md.template +14 -0
  75. gitwise/share/pi/skills/gitwise.md.template +14 -0
  76. gitwise/share/schemas/v1/input/audit.json +40 -0
  77. gitwise/share/schemas/v1/input/branches.json +51 -0
  78. gitwise/share/schemas/v1/input/clean.json +52 -0
  79. gitwise/share/schemas/v1/input/commands.json +36 -0
  80. gitwise/share/schemas/v1/input/commit.json +63 -0
  81. gitwise/share/schemas/v1/input/completions.json +51 -0
  82. gitwise/share/schemas/v1/input/conflicts.json +46 -0
  83. gitwise/share/schemas/v1/input/context.json +36 -0
  84. gitwise/share/schemas/v1/input/diff.json +56 -0
  85. gitwise/share/schemas/v1/input/doctor.json +36 -0
  86. gitwise/share/schemas/v1/input/health.json +36 -0
  87. gitwise/share/schemas/v1/input/log.json +71 -0
  88. gitwise/share/schemas/v1/input/merge.json +63 -0
  89. gitwise/share/schemas/v1/input/optimize.json +44 -0
  90. gitwise/share/schemas/v1/input/pick.json +63 -0
  91. gitwise/share/schemas/v1/input/pr.json +51 -0
  92. gitwise/share/schemas/v1/input/schema.json +48 -0
  93. gitwise/share/schemas/v1/input/setup-agents.json +108 -0
  94. gitwise/share/schemas/v1/input/setup.json +55 -0
  95. gitwise/share/schemas/v1/input/show.json +46 -0
  96. gitwise/share/schemas/v1/input/snapshot.json +36 -0
  97. gitwise/share/schemas/v1/input/stash.json +68 -0
  98. gitwise/share/schemas/v1/input/status.json +36 -0
  99. gitwise/share/schemas/v1/input/suggest.json +36 -0
  100. gitwise/share/schemas/v1/input/summarize.json +44 -0
  101. gitwise/share/schemas/v1/input/sync.json +55 -0
  102. gitwise/share/schemas/v1/input/tag.json +73 -0
  103. gitwise/share/schemas/v1/input/undo.json +60 -0
  104. gitwise/share/schemas/v1/input/update.json +40 -0
  105. gitwise/share/schemas/v1/input/worktree.json +50 -0
  106. gitwise/show.py +118 -0
  107. gitwise/snapshot.py +110 -0
  108. gitwise/stash.py +188 -0
  109. gitwise/status.py +93 -0
  110. gitwise/suggest.py +148 -0
  111. gitwise/summarize.py +202 -0
  112. gitwise/sync.py +257 -0
  113. gitwise/tag.py +252 -0
  114. gitwise/undo.py +145 -0
  115. gitwise/update.py +42 -0
  116. gitwise/utils/__init__.py +1 -0
  117. gitwise/utils/git_output.py +51 -0
  118. gitwise/utils/json_envelope.py +58 -0
  119. gitwise/utils/parsing.py +34 -0
  120. gitwise/worktree.py +182 -0
  121. gitwise_cli-0.24.2.dist-info/METADATA +151 -0
  122. gitwise_cli-0.24.2.dist-info/RECORD +125 -0
  123. gitwise_cli-0.24.2.dist-info/WHEEL +4 -0
  124. gitwise_cli-0.24.2.dist-info/entry_points.txt +2 -0
  125. 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