commitflow 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- commitflow-1.0.0.dist-info/METADATA +216 -0
- commitflow-1.0.0.dist-info/RECORD +19 -0
- commitflow-1.0.0.dist-info/WHEEL +5 -0
- commitflow-1.0.0.dist-info/entry_points.txt +2 -0
- commitflow-1.0.0.dist-info/licenses/LICENSE +21 -0
- commitflow-1.0.0.dist-info/top_level.txt +1 -0
- daily_git_assistant/__init__.py +1 -0
- daily_git_assistant/config.py +126 -0
- daily_git_assistant/git_utils.py +165 -0
- daily_git_assistant/logger.py +105 -0
- daily_git_assistant/main.py +108 -0
- daily_git_assistant/modes/__init__.py +0 -0
- daily_git_assistant/modes/auto.py +118 -0
- daily_git_assistant/modes/interactive.py +172 -0
- daily_git_assistant/modes/quick.py +143 -0
- daily_git_assistant/modes/setup.py +90 -0
- daily_git_assistant/repo_scanner.py +127 -0
- daily_git_assistant/scheduler.py +259 -0
- daily_git_assistant/ui.py +149 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from .git_utils import is_git_repo
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def scan_for_git_repos(base_directory=None, max_depth=3):
|
|
7
|
+
"""
|
|
8
|
+
Scan a directory recursively to find Git repositories.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
repos = []
|
|
12
|
+
|
|
13
|
+
if base_directory is None:
|
|
14
|
+
base_directory = Path.home()
|
|
15
|
+
|
|
16
|
+
base_directory = os.path.expanduser(base_directory)
|
|
17
|
+
|
|
18
|
+
for root, dirs, files in os.walk(base_directory):
|
|
19
|
+
|
|
20
|
+
# limit search depth
|
|
21
|
+
depth = root[len(base_directory):].count(os.sep)
|
|
22
|
+
if depth > max_depth:
|
|
23
|
+
dirs[:] = []
|
|
24
|
+
continue
|
|
25
|
+
|
|
26
|
+
if ".git" in dirs:
|
|
27
|
+
|
|
28
|
+
if is_git_repo(root):
|
|
29
|
+
repos.append(root)
|
|
30
|
+
|
|
31
|
+
dirs.remove(".git")
|
|
32
|
+
|
|
33
|
+
return repos
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def scan_common_dev_directories():
|
|
37
|
+
"""
|
|
38
|
+
Scan common development directories automatically.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
common_dirs = [
|
|
42
|
+
os.path.join(Path.home(), "projects"),
|
|
43
|
+
os.path.join(Path.home(), "Projects"),
|
|
44
|
+
os.path.join(Path.home(), "Documents"),
|
|
45
|
+
os.path.join(Path.home(), "workspace"),
|
|
46
|
+
os.path.join(Path.home(), "dev"),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
repos = []
|
|
50
|
+
|
|
51
|
+
for directory in common_dirs:
|
|
52
|
+
|
|
53
|
+
if os.path.exists(directory):
|
|
54
|
+
|
|
55
|
+
repos.extend(scan_for_git_repos(directory))
|
|
56
|
+
|
|
57
|
+
# remove duplicates
|
|
58
|
+
repos = list(set(repos))
|
|
59
|
+
|
|
60
|
+
return repos
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def display_repos(repos):
|
|
64
|
+
"""
|
|
65
|
+
Display detected repositories.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
if not repos:
|
|
69
|
+
|
|
70
|
+
print("No Git repositories found.")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
print("\nDetected Git Repositories:\n")
|
|
74
|
+
|
|
75
|
+
for index, repo in enumerate(repos, start=1):
|
|
76
|
+
|
|
77
|
+
name = os.path.basename(repo)
|
|
78
|
+
|
|
79
|
+
print(f"{index}. {name} ({repo})")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def select_repo(repos):
|
|
83
|
+
"""
|
|
84
|
+
Allow user to choose repository interactively.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
if not repos:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
display_repos(repos)
|
|
91
|
+
|
|
92
|
+
while True:
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
|
|
96
|
+
choice = input("\nSelect repository number ➤ ").strip()
|
|
97
|
+
|
|
98
|
+
index = int(choice) - 1
|
|
99
|
+
|
|
100
|
+
if 0 <= index < len(repos):
|
|
101
|
+
|
|
102
|
+
return repos[index]
|
|
103
|
+
|
|
104
|
+
else:
|
|
105
|
+
print("Invalid selection. Try again.")
|
|
106
|
+
|
|
107
|
+
except ValueError:
|
|
108
|
+
|
|
109
|
+
print("Enter a valid number.")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def auto_detect_repo():
|
|
113
|
+
"""
|
|
114
|
+
Auto detect repositories and allow selection.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
print("Scanning for Git repositories...")
|
|
118
|
+
|
|
119
|
+
repos = scan_common_dev_directories()
|
|
120
|
+
|
|
121
|
+
if not repos:
|
|
122
|
+
|
|
123
|
+
print("No repositories detected.")
|
|
124
|
+
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
return select_repo(repos)
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def detect_os():
|
|
8
|
+
return platform.system()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validate_time_format(time_str):
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
hour, minute = time_str.split(":")
|
|
15
|
+
hour = int(hour)
|
|
16
|
+
minute = int(minute)
|
|
17
|
+
|
|
18
|
+
return 0 <= hour <= 23 and 0 <= minute <= 59
|
|
19
|
+
|
|
20
|
+
except Exception:
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# -------------------------------------------
|
|
25
|
+
# USER SETTINGS PROMPT
|
|
26
|
+
# -------------------------------------------
|
|
27
|
+
|
|
28
|
+
def ask_boolean(question, default="n"):
|
|
29
|
+
|
|
30
|
+
value = input(f"{question} (y/n) [default {default}] ➤ ").strip().lower()
|
|
31
|
+
|
|
32
|
+
if not value:
|
|
33
|
+
value = default
|
|
34
|
+
|
|
35
|
+
return value == "y"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ask_scheduler_settings():
|
|
39
|
+
|
|
40
|
+
print("\n--- Scheduler Conditions ---")
|
|
41
|
+
|
|
42
|
+
idle = ask_boolean("Start only if computer is idle?", "n")
|
|
43
|
+
ac_power = ask_boolean("Start only if computer is on AC power?", "n")
|
|
44
|
+
stop_battery = ask_boolean("Stop task if switching to battery?", "n")
|
|
45
|
+
wake = ask_boolean("Wake computer to run task?", "y")
|
|
46
|
+
network = ask_boolean("Require network connection?", "n")
|
|
47
|
+
|
|
48
|
+
print("\n--- Scheduler Settings ---")
|
|
49
|
+
|
|
50
|
+
run_on_demand = ask_boolean("Allow task to run on demand?", "y")
|
|
51
|
+
run_missed = ask_boolean("Run task ASAP if missed?", "y")
|
|
52
|
+
restart = ask_boolean("Restart task if it fails?", "y")
|
|
53
|
+
|
|
54
|
+
restart_interval = "PT5M"
|
|
55
|
+
restart_count = "100"
|
|
56
|
+
|
|
57
|
+
stop_time = "P1D"
|
|
58
|
+
|
|
59
|
+
force_stop = ask_boolean("Force stop if task does not end?", "y")
|
|
60
|
+
|
|
61
|
+
delete_after = "PT0S"
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
"idle": idle,
|
|
65
|
+
"ac_power": ac_power,
|
|
66
|
+
"stop_battery": stop_battery,
|
|
67
|
+
"wake": wake,
|
|
68
|
+
"network": network,
|
|
69
|
+
"run_on_demand": run_on_demand,
|
|
70
|
+
"run_missed": run_missed,
|
|
71
|
+
"restart": restart,
|
|
72
|
+
"restart_interval": restart_interval,
|
|
73
|
+
"restart_count": restart_count,
|
|
74
|
+
"stop_time": stop_time,
|
|
75
|
+
"force_stop": force_stop,
|
|
76
|
+
"delete_after": delete_after
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# -------------------------------------------
|
|
81
|
+
# WINDOWS TASK CREATION
|
|
82
|
+
# -------------------------------------------
|
|
83
|
+
|
|
84
|
+
def create_windows_task(time_str, settings):
|
|
85
|
+
|
|
86
|
+
hour, minute = time_str.split(":")
|
|
87
|
+
|
|
88
|
+
restart_xml = ""
|
|
89
|
+
if settings["restart"]:
|
|
90
|
+
restart_xml = f"""
|
|
91
|
+
<RestartOnFailure>
|
|
92
|
+
<Interval>{settings['restart_interval']}</Interval>
|
|
93
|
+
<Count>{settings['restart_count']}</Count>
|
|
94
|
+
</RestartOnFailure>
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
xml_content = f"""<?xml version="1.0" encoding="UTF-16"?>
|
|
98
|
+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
99
|
+
|
|
100
|
+
<Triggers>
|
|
101
|
+
<CalendarTrigger>
|
|
102
|
+
<StartBoundary>2026-01-01T{hour}:{minute}:00</StartBoundary>
|
|
103
|
+
<Enabled>true</Enabled>
|
|
104
|
+
<ScheduleByDay>
|
|
105
|
+
<DaysInterval>1</DaysInterval>
|
|
106
|
+
</ScheduleByDay>
|
|
107
|
+
</CalendarTrigger>
|
|
108
|
+
</Triggers>
|
|
109
|
+
|
|
110
|
+
<Principals>
|
|
111
|
+
<Principal id="Author">
|
|
112
|
+
<LogonType>InteractiveToken</LogonType>
|
|
113
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
114
|
+
</Principal>
|
|
115
|
+
</Principals>
|
|
116
|
+
|
|
117
|
+
<Settings>
|
|
118
|
+
|
|
119
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
120
|
+
|
|
121
|
+
<DisallowStartIfOnBatteries>{str(settings['ac_power']).lower()}</DisallowStartIfOnBatteries>
|
|
122
|
+
|
|
123
|
+
<StopIfGoingOnBatteries>{str(settings['stop_battery']).lower()}</StopIfGoingOnBatteries>
|
|
124
|
+
|
|
125
|
+
<AllowHardTerminate>{str(settings['force_stop']).lower()}</AllowHardTerminate>
|
|
126
|
+
|
|
127
|
+
<StartWhenAvailable>{str(settings['run_missed']).lower()}</StartWhenAvailable>
|
|
128
|
+
|
|
129
|
+
<RunOnlyIfNetworkAvailable>{str(settings['network']).lower()}</RunOnlyIfNetworkAvailable>
|
|
130
|
+
|
|
131
|
+
<WakeToRun>{str(settings['wake']).lower()}</WakeToRun>
|
|
132
|
+
|
|
133
|
+
<ExecutionTimeLimit>{settings['stop_time']}</ExecutionTimeLimit>
|
|
134
|
+
|
|
135
|
+
{restart_xml}
|
|
136
|
+
|
|
137
|
+
<AllowStartOnDemand>{str(settings['run_on_demand']).lower()}</AllowStartOnDemand>
|
|
138
|
+
|
|
139
|
+
<Enabled>true</Enabled>
|
|
140
|
+
|
|
141
|
+
</Settings>
|
|
142
|
+
|
|
143
|
+
# <Actions Context="Author">
|
|
144
|
+
# <Exec>
|
|
145
|
+
# <Command>commitflow</Command>
|
|
146
|
+
# <Arguments>--auto</Arguments>
|
|
147
|
+
# </Exec>
|
|
148
|
+
# </Actions>
|
|
149
|
+
|
|
150
|
+
<Actions Context="Author">
|
|
151
|
+
<Exec>
|
|
152
|
+
<Command>python</Command>
|
|
153
|
+
<Arguments>-m daily_git_assistant.main --auto</Arguments>
|
|
154
|
+
</Exec>
|
|
155
|
+
</Actions>
|
|
156
|
+
|
|
157
|
+
</Task>
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
|
|
162
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".xml") as f:
|
|
163
|
+
|
|
164
|
+
f.write(xml_content.encode("utf-16"))
|
|
165
|
+
xml_path = f.name
|
|
166
|
+
|
|
167
|
+
subprocess.run(
|
|
168
|
+
[
|
|
169
|
+
"schtasks",
|
|
170
|
+
"/create",
|
|
171
|
+
"/tn",
|
|
172
|
+
"CommitFlowDaily",
|
|
173
|
+
"/xml",
|
|
174
|
+
xml_path,
|
|
175
|
+
"/f"
|
|
176
|
+
],
|
|
177
|
+
check=True
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
os.remove(xml_path)
|
|
181
|
+
|
|
182
|
+
print("\n[SUCCESS] Windows scheduled task created.")
|
|
183
|
+
|
|
184
|
+
except Exception as e:
|
|
185
|
+
|
|
186
|
+
print("[ERROR] Failed to create Windows scheduled task:", e)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# -------------------------------------------
|
|
190
|
+
# LINUX CRON
|
|
191
|
+
# -------------------------------------------
|
|
192
|
+
|
|
193
|
+
def create_linux_cron(time_str):
|
|
194
|
+
|
|
195
|
+
hour, minute = time_str.split(":")
|
|
196
|
+
|
|
197
|
+
cron_line = f"{minute} {hour} * * * commitflow --auto"
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
|
|
201
|
+
result = subprocess.run(
|
|
202
|
+
["crontab", "-l"],
|
|
203
|
+
capture_output=True,
|
|
204
|
+
text=True
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
existing = result.stdout
|
|
208
|
+
|
|
209
|
+
if cron_line in existing:
|
|
210
|
+
print("[INFO] Cron already exists.")
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
new_cron = existing + "\n" + cron_line + "\n"
|
|
214
|
+
|
|
215
|
+
p = subprocess.Popen(["crontab", "-"], stdin=subprocess.PIPE, text=True)
|
|
216
|
+
p.communicate(new_cron)
|
|
217
|
+
|
|
218
|
+
print("\n[SUCCESS] Cron job created.")
|
|
219
|
+
|
|
220
|
+
except Exception:
|
|
221
|
+
|
|
222
|
+
print("[ERROR] Failed to create cron job.")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# -------------------------------------------
|
|
226
|
+
# INTERACTIVE SCHEDULER
|
|
227
|
+
# -------------------------------------------
|
|
228
|
+
|
|
229
|
+
def schedule_interactive():
|
|
230
|
+
|
|
231
|
+
print("\nSelect Scheduler Type")
|
|
232
|
+
|
|
233
|
+
print("1. Windows Task Scheduler")
|
|
234
|
+
print("2. Linux Cron")
|
|
235
|
+
|
|
236
|
+
choice = input("Enter choice ➤ ").strip()
|
|
237
|
+
|
|
238
|
+
time_str = input("Run time (HH:MM) ➤ ").strip()
|
|
239
|
+
|
|
240
|
+
if not validate_time_format(time_str):
|
|
241
|
+
|
|
242
|
+
print("[ERROR] Invalid time format.")
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
os_type = detect_os()
|
|
246
|
+
|
|
247
|
+
if choice == "1" and os_type == "Windows":
|
|
248
|
+
|
|
249
|
+
settings = ask_scheduler_settings()
|
|
250
|
+
|
|
251
|
+
create_windows_task(time_str, settings)
|
|
252
|
+
|
|
253
|
+
elif choice == "2" and os_type != "Windows":
|
|
254
|
+
|
|
255
|
+
create_linux_cron(time_str)
|
|
256
|
+
|
|
257
|
+
else:
|
|
258
|
+
|
|
259
|
+
print("[WARNING] Scheduler type does not match OS.")
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from colorama import Fore, Style, init
|
|
2
|
+
|
|
3
|
+
init(autoreset=True)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def print_header(version="CommitFlow v1.0"):
|
|
7
|
+
"""
|
|
8
|
+
Display tool header.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
print(Fore.CYAN + Style.BRIGHT)
|
|
12
|
+
print("═══════════════════════════════════════")
|
|
13
|
+
print(f" {version}")
|
|
14
|
+
print(" Daily Git Consistency Assistant")
|
|
15
|
+
print("═══════════════════════════════════════")
|
|
16
|
+
print(Style.RESET_ALL)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def divider():
|
|
20
|
+
"""
|
|
21
|
+
Print a visual divider.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
print(Fore.CYAN + "────────────────────────────────────────" + Style.RESET_ALL)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def print_section(title):
|
|
28
|
+
"""
|
|
29
|
+
Display a section heading.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
divider()
|
|
33
|
+
print(Fore.YELLOW + Style.BRIGHT + title)
|
|
34
|
+
divider()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def info(message):
|
|
38
|
+
"""
|
|
39
|
+
Display informational message.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
print(Fore.CYAN + f"[INFO] {message}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def success(message):
|
|
46
|
+
"""
|
|
47
|
+
Display success message.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
print(Fore.GREEN + f"[SUCCESS] {message}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def warning(message):
|
|
54
|
+
"""
|
|
55
|
+
Display warning message.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
print(Fore.YELLOW + f"[WARNING] {message}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def error(message):
|
|
62
|
+
"""
|
|
63
|
+
Display error message.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
print(Fore.RED + f"[ERROR] {message}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def print_repo_info(repo, branch, remote):
|
|
70
|
+
"""
|
|
71
|
+
Display repository details.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
print(Fore.CYAN + Style.BRIGHT)
|
|
75
|
+
print(f"📦 Repository : {repo}")
|
|
76
|
+
print(f"🌿 Branch : {branch}")
|
|
77
|
+
print(f"🔗 Remote : {remote}")
|
|
78
|
+
print(Style.RESET_ALL)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def print_git_status(status_output):
|
|
82
|
+
"""
|
|
83
|
+
Print git status output.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
print(Fore.YELLOW + "\n📄 Git Status:\n" + Style.RESET_ALL)
|
|
87
|
+
print(status_output)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def show_commit_preview(diff_output):
|
|
91
|
+
"""
|
|
92
|
+
Display staged changes preview.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
print_section("Preview Staged Changes")
|
|
96
|
+
|
|
97
|
+
if not diff_output.strip():
|
|
98
|
+
|
|
99
|
+
warning("No staged changes detected.")
|
|
100
|
+
|
|
101
|
+
else:
|
|
102
|
+
|
|
103
|
+
print(diff_output)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def print_commit_summary(repo, branch, files, message, pushed):
|
|
107
|
+
"""
|
|
108
|
+
Display commit summary after operation.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
pushed_text = "Yes" if pushed else "No"
|
|
112
|
+
|
|
113
|
+
print("\n" + Fore.CYAN + Style.BRIGHT)
|
|
114
|
+
print("══════════════════════════")
|
|
115
|
+
print("Commit Summary")
|
|
116
|
+
print("══════════════════════════")
|
|
117
|
+
|
|
118
|
+
print(f"Repo : {repo}")
|
|
119
|
+
print(f"Branch : {branch}")
|
|
120
|
+
print(f"Files : {files}")
|
|
121
|
+
print(f"Message : {message}")
|
|
122
|
+
print(f"Pushed : {pushed_text}")
|
|
123
|
+
|
|
124
|
+
print("══════════════════════════")
|
|
125
|
+
print(Style.RESET_ALL)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def prompt(message):
|
|
129
|
+
"""
|
|
130
|
+
Input prompt helper.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
return input(Fore.CYAN + message + Style.RESET_ALL)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def restart_message():
|
|
137
|
+
"""
|
|
138
|
+
Display restart message for repeated commits.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
print(Fore.CYAN + "\n♻ Restarting session...\n")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def exit_message():
|
|
145
|
+
"""
|
|
146
|
+
Display exit message.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
print(Fore.GREEN + "\n👋 Exiting CommitFlow. Stay consistent!")
|