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/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)