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/branches.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""gitwise branches — intelligence dashboard with ahead/behind, merged, stale, worktree info."""
|
|
2
|
+
|
|
3
|
+
from .git import require_root, stale_branches, worktree_branches
|
|
4
|
+
from .git import run as git_run
|
|
5
|
+
from .i18n import t
|
|
6
|
+
from .output import error, info, print_dim, print_json, print_table
|
|
7
|
+
from .utils.json_envelope import ok_envelope
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _parse_branches(raw: str, wt_branches: set[str]) -> list[dict[str, str]]:
|
|
11
|
+
branches: list[dict[str, str]] = []
|
|
12
|
+
for line in raw.splitlines():
|
|
13
|
+
if not line.strip():
|
|
14
|
+
continue
|
|
15
|
+
parts = line.split("\t")
|
|
16
|
+
if len(parts) < 5:
|
|
17
|
+
continue
|
|
18
|
+
is_current = parts[0].strip() == "*"
|
|
19
|
+
name = parts[1].strip()
|
|
20
|
+
sha = parts[2]
|
|
21
|
+
subject = parts[3]
|
|
22
|
+
age = parts[4]
|
|
23
|
+
tracking = parts[5] if len(parts) > 5 else ""
|
|
24
|
+
upstream = parts[6] if len(parts) > 6 else ""
|
|
25
|
+
|
|
26
|
+
ahead = behind = ""
|
|
27
|
+
if "[ahead" in tracking:
|
|
28
|
+
ahead = tracking.split("ahead")[1].split(",")[0].strip().rstrip("]")
|
|
29
|
+
if "behind" in tracking:
|
|
30
|
+
behind = tracking.split("behind")[1].strip().rstrip("]")
|
|
31
|
+
|
|
32
|
+
branches.append(
|
|
33
|
+
{
|
|
34
|
+
"name": name,
|
|
35
|
+
"current": str(is_current).lower(),
|
|
36
|
+
"sha": sha,
|
|
37
|
+
"subject": subject,
|
|
38
|
+
"age": age,
|
|
39
|
+
"upstream": upstream,
|
|
40
|
+
"ahead": ahead,
|
|
41
|
+
"behind": behind,
|
|
42
|
+
"tracking": tracking,
|
|
43
|
+
"in_worktree": str(name in wt_branches).lower(),
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
return branches
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_VALID_SORT_FIELDS = frozenset(
|
|
50
|
+
{
|
|
51
|
+
"refname",
|
|
52
|
+
"-refname",
|
|
53
|
+
"committerdate",
|
|
54
|
+
"-committerdate",
|
|
55
|
+
"creatordate",
|
|
56
|
+
"-creatordate",
|
|
57
|
+
"authordate",
|
|
58
|
+
"-authordate",
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _print_stale_branches(*, names: list[str], as_json: bool) -> int:
|
|
64
|
+
if not names:
|
|
65
|
+
info(t("no_stale_branches"))
|
|
66
|
+
return 0
|
|
67
|
+
if as_json:
|
|
68
|
+
print_json(ok_envelope(stale_branches=names, count=len(names)))
|
|
69
|
+
return 0
|
|
70
|
+
for branch_name in names:
|
|
71
|
+
print_dim(branch_name)
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _fetch_branch_rows(*, root, remote: bool, sort: str) -> list[dict[str, str]] | None:
|
|
76
|
+
wt_branches = worktree_branches(cwd=root)
|
|
77
|
+
ref_pattern = "refs/remotes/" if remote else "refs/heads/"
|
|
78
|
+
fmt = "%(HEAD)\t%(refname:short)\t%(objectname:short)\t%(subject)\t%(committerdate:relative)\t%(upstream:track)\t%(upstream:short)"
|
|
79
|
+
result = git_run(
|
|
80
|
+
["for-each-ref", f"--sort={sort}", f"--format={fmt}", ref_pattern],
|
|
81
|
+
cwd=root,
|
|
82
|
+
check=False,
|
|
83
|
+
)
|
|
84
|
+
if result.returncode != 0:
|
|
85
|
+
error(t("git_ref_failed", error=result.stderr.strip()))
|
|
86
|
+
return None
|
|
87
|
+
if not result.stdout.strip():
|
|
88
|
+
if remote:
|
|
89
|
+
info(t("no_remote_branches"))
|
|
90
|
+
else:
|
|
91
|
+
info(t("no_commits_yet"))
|
|
92
|
+
return []
|
|
93
|
+
return _parse_branches(result.stdout, wt_branches)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _build_branch_rows(
|
|
97
|
+
branches: list[dict[str, str]],
|
|
98
|
+
) -> tuple[list[list[str]], set[int], int | None]:
|
|
99
|
+
rows: list[list[str]] = []
|
|
100
|
+
highlight_rows: set[int] = set()
|
|
101
|
+
current_idx: int | None = None
|
|
102
|
+
for idx, branch_item in enumerate(branches):
|
|
103
|
+
sha = branch_item["sha"][:8]
|
|
104
|
+
subject = branch_item["subject"][:40]
|
|
105
|
+
age = branch_item.get("age", "")
|
|
106
|
+
flags: list[str] = []
|
|
107
|
+
if branch_item.get("ahead"):
|
|
108
|
+
flags.append(f"↑{branch_item['ahead']}")
|
|
109
|
+
if branch_item.get("behind"):
|
|
110
|
+
flags.append(f"↓{branch_item['behind']}")
|
|
111
|
+
if branch_item.get("in_worktree") == "true":
|
|
112
|
+
flags.append("wt")
|
|
113
|
+
if branch_item.get("upstream"):
|
|
114
|
+
flags.append(f"→{branch_item['upstream']}")
|
|
115
|
+
status = " ".join(flags) if flags else ""
|
|
116
|
+
name_display = (
|
|
117
|
+
f"* {branch_item['name']}" if branch_item["current"] == "true" else branch_item["name"]
|
|
118
|
+
)
|
|
119
|
+
rows.append([name_display, sha, subject, age, status])
|
|
120
|
+
if branch_item["current"] == "true":
|
|
121
|
+
current_idx = idx
|
|
122
|
+
highlight_rows.add(idx)
|
|
123
|
+
return rows, highlight_rows, current_idx
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _print_branch_table(branches: list[dict[str, str]]) -> None:
|
|
127
|
+
columns = [
|
|
128
|
+
(t("col_branch"), "name"),
|
|
129
|
+
(t("col_sha"), "sha"),
|
|
130
|
+
(t("col_subject"), "subject"),
|
|
131
|
+
(t("col_age"), "age"),
|
|
132
|
+
(t("col_status"), "status"),
|
|
133
|
+
]
|
|
134
|
+
rows, highlight_rows, current_idx = _build_branch_rows(branches)
|
|
135
|
+
print_table(
|
|
136
|
+
title=t("branch_list_title_current", branch=branches[current_idx]["name"])
|
|
137
|
+
if current_idx is not None
|
|
138
|
+
else t("branch_list_title"),
|
|
139
|
+
columns=columns,
|
|
140
|
+
rows=rows,
|
|
141
|
+
highlight_rows=highlight_rows,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _validate_sort_field(sort: str) -> bool:
|
|
146
|
+
if sort in _VALID_SORT_FIELDS:
|
|
147
|
+
return True
|
|
148
|
+
error(t("invalid_sort_field", field=sort))
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def run_branches(
|
|
153
|
+
*,
|
|
154
|
+
stale: bool = False,
|
|
155
|
+
remote: bool = False,
|
|
156
|
+
sort: str = "refname",
|
|
157
|
+
as_json: bool = False,
|
|
158
|
+
) -> int:
|
|
159
|
+
root, err = require_root()
|
|
160
|
+
if err:
|
|
161
|
+
return err
|
|
162
|
+
if root is None:
|
|
163
|
+
return 1
|
|
164
|
+
|
|
165
|
+
if stale:
|
|
166
|
+
names = stale_branches(cwd=root)
|
|
167
|
+
return _print_stale_branches(names=names, as_json=as_json)
|
|
168
|
+
|
|
169
|
+
if not _validate_sort_field(sort):
|
|
170
|
+
return 1
|
|
171
|
+
|
|
172
|
+
branches = _fetch_branch_rows(root=root, remote=remote, sort=sort)
|
|
173
|
+
if branches is None:
|
|
174
|
+
return 1
|
|
175
|
+
if not branches:
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
if as_json:
|
|
179
|
+
print_json(ok_envelope(branches=branches, count=len(branches)))
|
|
180
|
+
return 0
|
|
181
|
+
_print_branch_table(branches)
|
|
182
|
+
|
|
183
|
+
return 0
|
gitwise/clean.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Deletes stale [gone] branches with mandatory confirmation. Never silent."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .git import (
|
|
6
|
+
current_branch,
|
|
7
|
+
require_root,
|
|
8
|
+
stale_branches,
|
|
9
|
+
worktree_branches,
|
|
10
|
+
)
|
|
11
|
+
from .git import (
|
|
12
|
+
run as git_run,
|
|
13
|
+
)
|
|
14
|
+
from .i18n import t
|
|
15
|
+
from .output import (
|
|
16
|
+
confirm,
|
|
17
|
+
error,
|
|
18
|
+
ok,
|
|
19
|
+
print_blank,
|
|
20
|
+
print_bracket,
|
|
21
|
+
print_dim,
|
|
22
|
+
print_header,
|
|
23
|
+
print_json,
|
|
24
|
+
print_success,
|
|
25
|
+
warn,
|
|
26
|
+
)
|
|
27
|
+
from .utils.json_envelope import error_envelope, ok_envelope
|
|
28
|
+
|
|
29
|
+
_DEFAULT_PROTECTED: frozenset[str] = frozenset(
|
|
30
|
+
{"main", "master", "develop", "dev", "trunk", "release"}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _categorize(
|
|
35
|
+
cwd: Path, extra_protected: set[str] | None = None
|
|
36
|
+
) -> tuple[list[str], list[dict[str, str]]]:
|
|
37
|
+
"""Returns (deletable, skipped_with_reasons) for all stale branches."""
|
|
38
|
+
protected = _DEFAULT_PROTECTED | (extra_protected or set())
|
|
39
|
+
checked_out = current_branch(cwd)
|
|
40
|
+
active_wt = worktree_branches(cwd)
|
|
41
|
+
|
|
42
|
+
deletable: list[str] = []
|
|
43
|
+
skipped: list[dict] = []
|
|
44
|
+
|
|
45
|
+
for branch in stale_branches(cwd):
|
|
46
|
+
if branch in protected:
|
|
47
|
+
skipped.append({"branch": branch, "reason": t("protected_branch")})
|
|
48
|
+
elif branch == checked_out:
|
|
49
|
+
skipped.append({"branch": branch, "reason": t("current_branch_msg")})
|
|
50
|
+
elif branch in active_wt:
|
|
51
|
+
skipped.append({"branch": branch, "reason": t("active_in_worktree")})
|
|
52
|
+
else:
|
|
53
|
+
deletable.append(branch)
|
|
54
|
+
|
|
55
|
+
return deletable, skipped
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_clean(
|
|
59
|
+
*,
|
|
60
|
+
branches: bool = False,
|
|
61
|
+
refs: bool = False,
|
|
62
|
+
dry_run: bool = False,
|
|
63
|
+
yes: bool = False,
|
|
64
|
+
as_json: bool = False,
|
|
65
|
+
) -> int:
|
|
66
|
+
if refs:
|
|
67
|
+
error(t("clean_refs_not_implemented"))
|
|
68
|
+
return 1
|
|
69
|
+
|
|
70
|
+
if not branches:
|
|
71
|
+
error(t("clean_specify_flag"))
|
|
72
|
+
return 1
|
|
73
|
+
|
|
74
|
+
root, err = require_root()
|
|
75
|
+
if err:
|
|
76
|
+
return err
|
|
77
|
+
if root is None:
|
|
78
|
+
return 1
|
|
79
|
+
cwd = root
|
|
80
|
+
|
|
81
|
+
deletable, skipped = _categorize(cwd)
|
|
82
|
+
|
|
83
|
+
if dry_run:
|
|
84
|
+
if as_json:
|
|
85
|
+
print_json(
|
|
86
|
+
ok_envelope(
|
|
87
|
+
payload={
|
|
88
|
+
"dry_run": True,
|
|
89
|
+
"applied": False,
|
|
90
|
+
"deletable": deletable,
|
|
91
|
+
"skipped": skipped,
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
return 0
|
|
96
|
+
if not deletable and not skipped:
|
|
97
|
+
ok(t("no_stale_branches"))
|
|
98
|
+
return 0
|
|
99
|
+
if skipped:
|
|
100
|
+
print_header(t("protected_stale_branches", count=str(len(skipped))))
|
|
101
|
+
for s in skipped:
|
|
102
|
+
print_bracket(s["branch"], s["reason"])
|
|
103
|
+
print_blank()
|
|
104
|
+
if not deletable:
|
|
105
|
+
ok(t("no_deletable_branches"))
|
|
106
|
+
return 0
|
|
107
|
+
print_header(t("branches_to_delete", count=str(len(deletable))))
|
|
108
|
+
for branch in deletable:
|
|
109
|
+
print_bracket(branch)
|
|
110
|
+
print_blank()
|
|
111
|
+
print_dim(t("dry_run_no_delete"))
|
|
112
|
+
print_dim(t("clean_to_delete"))
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
if as_json and not yes:
|
|
116
|
+
print_json(
|
|
117
|
+
error_envelope(
|
|
118
|
+
error=t("yes_required_with_json"),
|
|
119
|
+
code="yes_required",
|
|
120
|
+
hint=t("yes_required_hint"),
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
return 2
|
|
124
|
+
|
|
125
|
+
if not as_json:
|
|
126
|
+
if not deletable and not skipped:
|
|
127
|
+
ok(t("no_stale_branches"))
|
|
128
|
+
return 0
|
|
129
|
+
if skipped:
|
|
130
|
+
print_header(t("protected_stale_branches", count=str(len(skipped))))
|
|
131
|
+
for s in skipped:
|
|
132
|
+
print_bracket(s["branch"], s["reason"])
|
|
133
|
+
print_blank()
|
|
134
|
+
if not deletable:
|
|
135
|
+
ok(t("no_deletable_branches"))
|
|
136
|
+
return 0
|
|
137
|
+
print_header(t("branches_to_delete", count=str(len(deletable))))
|
|
138
|
+
for branch in deletable:
|
|
139
|
+
print_bracket(branch)
|
|
140
|
+
print_blank()
|
|
141
|
+
if not yes:
|
|
142
|
+
if not confirm(t("confirm_delete_branches", count=str(len(deletable)))):
|
|
143
|
+
print_dim(t("cancelled"))
|
|
144
|
+
return 0
|
|
145
|
+
print_blank()
|
|
146
|
+
elif not deletable:
|
|
147
|
+
print_json(
|
|
148
|
+
ok_envelope(
|
|
149
|
+
payload={
|
|
150
|
+
"dry_run": False,
|
|
151
|
+
"applied": True,
|
|
152
|
+
"deleted": [],
|
|
153
|
+
"skipped": skipped,
|
|
154
|
+
"delete_errors": [],
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
deleted: list[str] = []
|
|
161
|
+
delete_errors: list[dict[str, str]] = []
|
|
162
|
+
for branch in deletable:
|
|
163
|
+
r = git_run(["branch", "-D", branch], cwd=cwd, check=False)
|
|
164
|
+
if r.returncode == 0:
|
|
165
|
+
deleted.append(branch)
|
|
166
|
+
if not as_json:
|
|
167
|
+
print_success(t("branch_deleted", branch=branch))
|
|
168
|
+
else:
|
|
169
|
+
delete_errors.append({"branch": branch, "error": r.stderr.strip()})
|
|
170
|
+
if not as_json:
|
|
171
|
+
warn(t("could_not_delete", branch=branch, error=r.stderr.strip()))
|
|
172
|
+
|
|
173
|
+
if as_json:
|
|
174
|
+
payload: dict[str, object] = {
|
|
175
|
+
"dry_run": False,
|
|
176
|
+
"applied": True,
|
|
177
|
+
"deleted": deleted,
|
|
178
|
+
"skipped": skipped,
|
|
179
|
+
"delete_errors": delete_errors,
|
|
180
|
+
}
|
|
181
|
+
if delete_errors:
|
|
182
|
+
print_json(
|
|
183
|
+
error_envelope(
|
|
184
|
+
error=t("clean_delete_failures", count=str(len(delete_errors))),
|
|
185
|
+
code="clean_delete_failures",
|
|
186
|
+
payload=payload,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
return 1
|
|
190
|
+
print_json(ok_envelope(payload=payload))
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
if delete_errors:
|
|
194
|
+
return 1
|
|
195
|
+
print_blank()
|
|
196
|
+
ok(t("deleted_count", count=str(len(deletable))))
|
|
197
|
+
return 0
|
gitwise/commit.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""gitwise commit — conventional format validation, GPG enforcement, --amend protection."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .git import PROTECTED_BRANCHES, current_branch, gpg_status, require_root
|
|
7
|
+
from .git import run as git_run
|
|
8
|
+
from .i18n import t
|
|
9
|
+
from .output import error, print_bracket, print_header, print_json
|
|
10
|
+
from .utils.json_envelope import error_envelope, ok_envelope
|
|
11
|
+
|
|
12
|
+
_CONVENTIONAL_RE = re.compile(
|
|
13
|
+
r"^(feat|fix|refactor|docs|chore|test|style|perf|ci|build|revert)(\(.+\))?!?: .{1,72}"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_pushed(branch: str, cwd: Path) -> bool:
|
|
18
|
+
r = git_run(["rev-parse", "--verify", branch + "@{u}"], cwd=cwd, check=False)
|
|
19
|
+
return r.returncode == 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _compose_message(
|
|
23
|
+
*,
|
|
24
|
+
message: str,
|
|
25
|
+
type: str | None,
|
|
26
|
+
scope: str | None,
|
|
27
|
+
breaking: bool,
|
|
28
|
+
) -> str:
|
|
29
|
+
if _CONVENTIONAL_RE.match(message.split("\n")[0]) and not type:
|
|
30
|
+
return message
|
|
31
|
+
if not type:
|
|
32
|
+
return message
|
|
33
|
+
prefix = type
|
|
34
|
+
if scope:
|
|
35
|
+
prefix += f"({scope})"
|
|
36
|
+
if breaking:
|
|
37
|
+
prefix += "!"
|
|
38
|
+
return f"{prefix}: {message}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _validate_amend_policy(*, amend: bool, root: Path) -> int:
|
|
42
|
+
if not amend:
|
|
43
|
+
return 0
|
|
44
|
+
branch = current_branch(cwd=root) or ""
|
|
45
|
+
if branch in PROTECTED_BRANCHES:
|
|
46
|
+
error(t("commit_amend_protected", branch=branch))
|
|
47
|
+
return 1
|
|
48
|
+
if _is_pushed(branch, root):
|
|
49
|
+
error(t("commit_amend_pushed", branch=branch))
|
|
50
|
+
return 1
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _print_dry_run(*, message: str, amend: bool, root: Path) -> None:
|
|
55
|
+
print_header(t("dry_run_no_exec"))
|
|
56
|
+
print_bracket(t("commit_msg_label"), message)
|
|
57
|
+
if amend:
|
|
58
|
+
print_bracket(t("commit_amend_label"))
|
|
59
|
+
branch = current_branch(cwd=root) or ""
|
|
60
|
+
if branch:
|
|
61
|
+
print_bracket(t("commit_branch_label"), branch)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _validate_commit_message(message: str) -> bool:
|
|
65
|
+
if _CONVENTIONAL_RE.match(message.split("\n")[0]):
|
|
66
|
+
return True
|
|
67
|
+
error(t("commit_invalid_format"))
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _execute_commit(*, root: Path, message: str, amend: bool) -> tuple[bool, str]:
|
|
72
|
+
args = ["commit", "-m", message]
|
|
73
|
+
if amend:
|
|
74
|
+
args.append("--amend")
|
|
75
|
+
result = git_run(args, cwd=root, check=False)
|
|
76
|
+
if result.returncode == 0:
|
|
77
|
+
return True, ""
|
|
78
|
+
return False, t("git_command_failed", cmd="commit", error=result.stderr.strip())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _validate_gpg_ready(root: Path) -> bool:
|
|
82
|
+
gpg = gpg_status(cwd=root)
|
|
83
|
+
if gpg["gpgsign_enabled"] and not gpg["ready"]:
|
|
84
|
+
error(t("gpg_signing_active_no_key"))
|
|
85
|
+
return False
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _report_commit_error(*, as_json: bool, err: str) -> int:
|
|
90
|
+
if as_json:
|
|
91
|
+
print_json(error_envelope(error=err))
|
|
92
|
+
else:
|
|
93
|
+
error(err)
|
|
94
|
+
return 1
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def run_commit(
|
|
98
|
+
*,
|
|
99
|
+
message: str | None = None,
|
|
100
|
+
type: str | None = None,
|
|
101
|
+
scope: str | None = None,
|
|
102
|
+
breaking: bool = False,
|
|
103
|
+
amend: bool = False,
|
|
104
|
+
dry_run: bool = False,
|
|
105
|
+
as_json: bool = False,
|
|
106
|
+
) -> int:
|
|
107
|
+
root, err = require_root()
|
|
108
|
+
if err:
|
|
109
|
+
return err
|
|
110
|
+
if root is None:
|
|
111
|
+
return 1
|
|
112
|
+
|
|
113
|
+
amend_policy_rc = _validate_amend_policy(amend=amend, root=root)
|
|
114
|
+
if amend_policy_rc != 0:
|
|
115
|
+
return amend_policy_rc
|
|
116
|
+
|
|
117
|
+
if message is None:
|
|
118
|
+
error(t("commit_no_message"))
|
|
119
|
+
return 1
|
|
120
|
+
|
|
121
|
+
full_msg = _compose_message(message=message, type=type, scope=scope, breaking=breaking)
|
|
122
|
+
|
|
123
|
+
if not _validate_commit_message(full_msg):
|
|
124
|
+
return 1
|
|
125
|
+
|
|
126
|
+
if not _validate_gpg_ready(root):
|
|
127
|
+
return 1
|
|
128
|
+
|
|
129
|
+
if dry_run:
|
|
130
|
+
if as_json:
|
|
131
|
+
print_json(ok_envelope(message=full_msg, amend=amend, dry_run=True))
|
|
132
|
+
else:
|
|
133
|
+
_print_dry_run(message=full_msg, amend=amend, root=root)
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
success, err = _execute_commit(root=root, message=full_msg, amend=amend)
|
|
137
|
+
if not success:
|
|
138
|
+
return _report_commit_error(as_json=as_json, err=err)
|
|
139
|
+
|
|
140
|
+
if as_json:
|
|
141
|
+
print_json(ok_envelope(message=full_msg, amend=amend))
|
|
142
|
+
return 0
|
gitwise/conflicts.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""gitwise conflicts — conflict detection and resolution helper."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
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, ok, print_accent, print_blank, print_dim, print_header, print_json
|
|
9
|
+
from .utils.json_envelope import error_envelope, ok_envelope
|
|
10
|
+
from .utils.parsing import stripped_non_empty_lines, to_int
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _find_conflict_files(root: Path) -> list[str]:
|
|
14
|
+
r = git_run(["diff", "--name-only", "--diff-filter=U"], cwd=root, check=False)
|
|
15
|
+
if r.returncode != 0 or not r.stdout.strip():
|
|
16
|
+
return []
|
|
17
|
+
return stripped_non_empty_lines(r.stdout)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _conflict_markers(root: Path, filepath: str) -> int:
|
|
21
|
+
r = git_run(["grep", "-c", "<<<<<<< ", "--", filepath], cwd=root, check=False)
|
|
22
|
+
if r.returncode != 0:
|
|
23
|
+
return 0
|
|
24
|
+
count = 0
|
|
25
|
+
for line in r.stdout.splitlines():
|
|
26
|
+
marker_count = line.rsplit(":", 1)[-1]
|
|
27
|
+
count += max(to_int(marker_count, default=0), 0)
|
|
28
|
+
return count
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve_all_conflicts(*, root: Path, conflicts: list[str], strategy: str) -> int:
|
|
32
|
+
checkout_flag = "--ours" if strategy == "ours" else "--theirs"
|
|
33
|
+
result = git_run(["checkout", checkout_flag, "--"] + conflicts, cwd=root, check=False)
|
|
34
|
+
if result.returncode != 0:
|
|
35
|
+
error(result.stderr.strip())
|
|
36
|
+
return 1
|
|
37
|
+
git_run(["add", "--"] + conflicts, cwd=root, check=False)
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _conflict_details(root: Path, conflicts: list[str]) -> list[dict[str, str | int]]:
|
|
42
|
+
details: list[dict[str, str | int]] = []
|
|
43
|
+
for file_path in conflicts:
|
|
44
|
+
markers = _conflict_markers(root, file_path)
|
|
45
|
+
details.append({"file": file_path, "markers": markers})
|
|
46
|
+
return details
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _report_no_conflicts(*, as_json: bool) -> int:
|
|
50
|
+
if as_json:
|
|
51
|
+
print_json(ok_envelope(conflicts=[], count=0))
|
|
52
|
+
return 0
|
|
53
|
+
ok(t("conflicts_none"))
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _resolve_by_strategy(*, root: Path, conflicts: list[str], strategy: str, as_json: bool) -> int:
|
|
58
|
+
rc = _resolve_all_conflicts(root=root, conflicts=conflicts, strategy=strategy)
|
|
59
|
+
if rc != 0:
|
|
60
|
+
return rc
|
|
61
|
+
if as_json:
|
|
62
|
+
print_json(ok_envelope(resolved=len(conflicts), strategy=strategy))
|
|
63
|
+
return 0
|
|
64
|
+
key = "conflicts_resolved_ours" if strategy == "ours" else "conflicts_resolved_theirs"
|
|
65
|
+
ok(t(key, count=str(len(conflicts))))
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _report_conflicts(*, details: list[dict[str, str | int]], count: int, as_json: bool) -> int:
|
|
70
|
+
if as_json:
|
|
71
|
+
print_json(error_envelope(error=t("merge_conflicts"), conflicts=details, count=count))
|
|
72
|
+
return 0
|
|
73
|
+
print_header(t("conflicts_found", count=str(count)))
|
|
74
|
+
for detail in details:
|
|
75
|
+
print_accent(f" {detail['file']} ({detail['markers']} {t('markers_label')})")
|
|
76
|
+
print_blank()
|
|
77
|
+
print_dim(t("conflicts_hint"))
|
|
78
|
+
return 1
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def run_conflicts(
|
|
82
|
+
*,
|
|
83
|
+
ours: bool = False,
|
|
84
|
+
theirs: bool = False,
|
|
85
|
+
as_json: bool = False,
|
|
86
|
+
) -> int:
|
|
87
|
+
root, err = require_root()
|
|
88
|
+
if err:
|
|
89
|
+
return err
|
|
90
|
+
if root is None:
|
|
91
|
+
return 1
|
|
92
|
+
|
|
93
|
+
conflicts = _find_conflict_files(root)
|
|
94
|
+
|
|
95
|
+
if not conflicts:
|
|
96
|
+
return _report_no_conflicts(as_json=as_json)
|
|
97
|
+
|
|
98
|
+
if ours:
|
|
99
|
+
return _resolve_by_strategy(
|
|
100
|
+
root=root, conflicts=conflicts, strategy="ours", as_json=as_json
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if theirs:
|
|
104
|
+
return _resolve_by_strategy(
|
|
105
|
+
root=root,
|
|
106
|
+
conflicts=conflicts,
|
|
107
|
+
strategy="theirs",
|
|
108
|
+
as_json=as_json,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
details = _conflict_details(root, conflicts)
|
|
112
|
+
return _report_conflicts(details=details, count=len(conflicts), as_json=as_json)
|