@hasna/terminal 2.2.0 → 2.3.0

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 (205) hide show
  1. package/dist/cli.js +29 -12
  2. package/package.json +1 -1
  3. package/src/ai.ts +50 -36
  4. package/src/cli.tsx +29 -12
  5. package/src/context-hints.ts +89 -0
  6. package/src/discover.ts +238 -0
  7. package/src/economy.ts +53 -0
  8. package/src/output-store.ts +65 -0
  9. package/src/providers/index.ts +4 -4
  10. package/src/sessions-db.ts +81 -0
  11. package/temp/rtk/.claude/agents/code-reviewer.md +221 -0
  12. package/temp/rtk/.claude/agents/debugger.md +519 -0
  13. package/temp/rtk/.claude/agents/rtk-testing-specialist.md +461 -0
  14. package/temp/rtk/.claude/agents/rust-rtk.md +511 -0
  15. package/temp/rtk/.claude/agents/technical-writer.md +355 -0
  16. package/temp/rtk/.claude/commands/diagnose.md +352 -0
  17. package/temp/rtk/.claude/commands/test-routing.md +362 -0
  18. package/temp/rtk/.claude/hooks/bash/pre-commit-format.sh +16 -0
  19. package/temp/rtk/.claude/hooks/rtk-rewrite.sh +70 -0
  20. package/temp/rtk/.claude/hooks/rtk-suggest.sh +152 -0
  21. package/temp/rtk/.claude/rules/cli-testing.md +526 -0
  22. package/temp/rtk/.claude/skills/issue-triage/SKILL.md +348 -0
  23. package/temp/rtk/.claude/skills/issue-triage/templates/issue-comment.md +134 -0
  24. package/temp/rtk/.claude/skills/performance.md +435 -0
  25. package/temp/rtk/.claude/skills/pr-triage/SKILL.md +315 -0
  26. package/temp/rtk/.claude/skills/pr-triage/templates/review-comment.md +71 -0
  27. package/temp/rtk/.claude/skills/repo-recap.md +206 -0
  28. package/temp/rtk/.claude/skills/rtk-tdd/SKILL.md +78 -0
  29. package/temp/rtk/.claude/skills/rtk-tdd/references/testing-patterns.md +124 -0
  30. package/temp/rtk/.claude/skills/security-guardian.md +503 -0
  31. package/temp/rtk/.claude/skills/ship.md +404 -0
  32. package/temp/rtk/.github/workflows/benchmark.yml +34 -0
  33. package/temp/rtk/.github/workflows/dco-check.yaml +12 -0
  34. package/temp/rtk/.github/workflows/release-please.yml +51 -0
  35. package/temp/rtk/.github/workflows/release.yml +343 -0
  36. package/temp/rtk/.github/workflows/security-check.yml +135 -0
  37. package/temp/rtk/.github/workflows/validate-docs.yml +78 -0
  38. package/temp/rtk/.release-please-manifest.json +3 -0
  39. package/temp/rtk/ARCHITECTURE.md +1491 -0
  40. package/temp/rtk/CHANGELOG.md +640 -0
  41. package/temp/rtk/CLAUDE.md +605 -0
  42. package/temp/rtk/CONTRIBUTING.md +199 -0
  43. package/temp/rtk/Cargo.lock +1668 -0
  44. package/temp/rtk/Cargo.toml +64 -0
  45. package/temp/rtk/Formula/rtk.rb +43 -0
  46. package/temp/rtk/INSTALL.md +390 -0
  47. package/temp/rtk/LICENSE +21 -0
  48. package/temp/rtk/README.md +386 -0
  49. package/temp/rtk/README_es.md +159 -0
  50. package/temp/rtk/README_fr.md +197 -0
  51. package/temp/rtk/README_ja.md +159 -0
  52. package/temp/rtk/README_ko.md +159 -0
  53. package/temp/rtk/README_zh.md +167 -0
  54. package/temp/rtk/ROADMAP.md +15 -0
  55. package/temp/rtk/SECURITY.md +217 -0
  56. package/temp/rtk/TEST_EXEC_TIME.md +102 -0
  57. package/temp/rtk/build.rs +57 -0
  58. package/temp/rtk/docs/AUDIT_GUIDE.md +432 -0
  59. package/temp/rtk/docs/FEATURES.md +1410 -0
  60. package/temp/rtk/docs/TROUBLESHOOTING.md +309 -0
  61. package/temp/rtk/docs/filter-workflow.md +102 -0
  62. package/temp/rtk/docs/images/gain-dashboard.jpg +0 -0
  63. package/temp/rtk/docs/tracking.md +583 -0
  64. package/temp/rtk/hooks/opencode-rtk.ts +39 -0
  65. package/temp/rtk/hooks/rtk-awareness.md +29 -0
  66. package/temp/rtk/hooks/rtk-rewrite.sh +61 -0
  67. package/temp/rtk/hooks/test-rtk-rewrite.sh +442 -0
  68. package/temp/rtk/install.sh +124 -0
  69. package/temp/rtk/release-please-config.json +10 -0
  70. package/temp/rtk/scripts/benchmark.sh +592 -0
  71. package/temp/rtk/scripts/check-installation.sh +162 -0
  72. package/temp/rtk/scripts/install-local.sh +37 -0
  73. package/temp/rtk/scripts/rtk-economics.sh +137 -0
  74. package/temp/rtk/scripts/test-all.sh +561 -0
  75. package/temp/rtk/scripts/test-aristote.sh +227 -0
  76. package/temp/rtk/scripts/test-tracking.sh +79 -0
  77. package/temp/rtk/scripts/update-readme-metrics.sh +32 -0
  78. package/temp/rtk/scripts/validate-docs.sh +73 -0
  79. package/temp/rtk/src/aws_cmd.rs +880 -0
  80. package/temp/rtk/src/binlog.rs +1645 -0
  81. package/temp/rtk/src/cargo_cmd.rs +1727 -0
  82. package/temp/rtk/src/cc_economics.rs +1157 -0
  83. package/temp/rtk/src/ccusage.rs +340 -0
  84. package/temp/rtk/src/config.rs +187 -0
  85. package/temp/rtk/src/container.rs +855 -0
  86. package/temp/rtk/src/curl_cmd.rs +134 -0
  87. package/temp/rtk/src/deps.rs +268 -0
  88. package/temp/rtk/src/diff_cmd.rs +367 -0
  89. package/temp/rtk/src/discover/mod.rs +274 -0
  90. package/temp/rtk/src/discover/provider.rs +388 -0
  91. package/temp/rtk/src/discover/registry.rs +2022 -0
  92. package/temp/rtk/src/discover/report.rs +202 -0
  93. package/temp/rtk/src/discover/rules.rs +667 -0
  94. package/temp/rtk/src/display_helpers.rs +402 -0
  95. package/temp/rtk/src/dotnet_cmd.rs +1771 -0
  96. package/temp/rtk/src/dotnet_format_report.rs +133 -0
  97. package/temp/rtk/src/dotnet_trx.rs +593 -0
  98. package/temp/rtk/src/env_cmd.rs +204 -0
  99. package/temp/rtk/src/filter.rs +462 -0
  100. package/temp/rtk/src/filters/README.md +52 -0
  101. package/temp/rtk/src/filters/ansible-playbook.toml +34 -0
  102. package/temp/rtk/src/filters/basedpyright.toml +47 -0
  103. package/temp/rtk/src/filters/biome.toml +45 -0
  104. package/temp/rtk/src/filters/brew-install.toml +37 -0
  105. package/temp/rtk/src/filters/composer-install.toml +40 -0
  106. package/temp/rtk/src/filters/df.toml +16 -0
  107. package/temp/rtk/src/filters/dotnet-build.toml +64 -0
  108. package/temp/rtk/src/filters/du.toml +16 -0
  109. package/temp/rtk/src/filters/fail2ban-client.toml +15 -0
  110. package/temp/rtk/src/filters/gcc.toml +49 -0
  111. package/temp/rtk/src/filters/gcloud.toml +22 -0
  112. package/temp/rtk/src/filters/hadolint.toml +24 -0
  113. package/temp/rtk/src/filters/helm.toml +29 -0
  114. package/temp/rtk/src/filters/iptables.toml +27 -0
  115. package/temp/rtk/src/filters/jj.toml +28 -0
  116. package/temp/rtk/src/filters/jq.toml +24 -0
  117. package/temp/rtk/src/filters/make.toml +41 -0
  118. package/temp/rtk/src/filters/markdownlint.toml +24 -0
  119. package/temp/rtk/src/filters/mix-compile.toml +27 -0
  120. package/temp/rtk/src/filters/mix-format.toml +15 -0
  121. package/temp/rtk/src/filters/mvn-build.toml +44 -0
  122. package/temp/rtk/src/filters/oxlint.toml +43 -0
  123. package/temp/rtk/src/filters/ping.toml +63 -0
  124. package/temp/rtk/src/filters/pio-run.toml +40 -0
  125. package/temp/rtk/src/filters/poetry-install.toml +50 -0
  126. package/temp/rtk/src/filters/pre-commit.toml +35 -0
  127. package/temp/rtk/src/filters/ps.toml +16 -0
  128. package/temp/rtk/src/filters/quarto-render.toml +41 -0
  129. package/temp/rtk/src/filters/rsync.toml +48 -0
  130. package/temp/rtk/src/filters/shellcheck.toml +27 -0
  131. package/temp/rtk/src/filters/shopify-theme.toml +29 -0
  132. package/temp/rtk/src/filters/skopeo.toml +45 -0
  133. package/temp/rtk/src/filters/sops.toml +16 -0
  134. package/temp/rtk/src/filters/ssh.toml +44 -0
  135. package/temp/rtk/src/filters/stat.toml +34 -0
  136. package/temp/rtk/src/filters/swift-build.toml +41 -0
  137. package/temp/rtk/src/filters/systemctl-status.toml +33 -0
  138. package/temp/rtk/src/filters/terraform-plan.toml +35 -0
  139. package/temp/rtk/src/filters/tofu-fmt.toml +16 -0
  140. package/temp/rtk/src/filters/tofu-init.toml +38 -0
  141. package/temp/rtk/src/filters/tofu-plan.toml +35 -0
  142. package/temp/rtk/src/filters/tofu-validate.toml +17 -0
  143. package/temp/rtk/src/filters/trunk-build.toml +39 -0
  144. package/temp/rtk/src/filters/ty.toml +50 -0
  145. package/temp/rtk/src/filters/uv-sync.toml +37 -0
  146. package/temp/rtk/src/filters/xcodebuild.toml +99 -0
  147. package/temp/rtk/src/filters/yamllint.toml +25 -0
  148. package/temp/rtk/src/find_cmd.rs +598 -0
  149. package/temp/rtk/src/format_cmd.rs +386 -0
  150. package/temp/rtk/src/gain.rs +723 -0
  151. package/temp/rtk/src/gh_cmd.rs +1651 -0
  152. package/temp/rtk/src/git.rs +2012 -0
  153. package/temp/rtk/src/go_cmd.rs +592 -0
  154. package/temp/rtk/src/golangci_cmd.rs +254 -0
  155. package/temp/rtk/src/grep_cmd.rs +288 -0
  156. package/temp/rtk/src/gt_cmd.rs +810 -0
  157. package/temp/rtk/src/hook_audit_cmd.rs +283 -0
  158. package/temp/rtk/src/hook_check.rs +171 -0
  159. package/temp/rtk/src/init.rs +1859 -0
  160. package/temp/rtk/src/integrity.rs +537 -0
  161. package/temp/rtk/src/json_cmd.rs +231 -0
  162. package/temp/rtk/src/learn/detector.rs +628 -0
  163. package/temp/rtk/src/learn/mod.rs +119 -0
  164. package/temp/rtk/src/learn/report.rs +184 -0
  165. package/temp/rtk/src/lint_cmd.rs +694 -0
  166. package/temp/rtk/src/local_llm.rs +316 -0
  167. package/temp/rtk/src/log_cmd.rs +248 -0
  168. package/temp/rtk/src/ls.rs +324 -0
  169. package/temp/rtk/src/main.rs +2482 -0
  170. package/temp/rtk/src/mypy_cmd.rs +389 -0
  171. package/temp/rtk/src/next_cmd.rs +241 -0
  172. package/temp/rtk/src/npm_cmd.rs +236 -0
  173. package/temp/rtk/src/parser/README.md +267 -0
  174. package/temp/rtk/src/parser/error.rs +46 -0
  175. package/temp/rtk/src/parser/formatter.rs +336 -0
  176. package/temp/rtk/src/parser/mod.rs +311 -0
  177. package/temp/rtk/src/parser/types.rs +119 -0
  178. package/temp/rtk/src/pip_cmd.rs +302 -0
  179. package/temp/rtk/src/playwright_cmd.rs +479 -0
  180. package/temp/rtk/src/pnpm_cmd.rs +573 -0
  181. package/temp/rtk/src/prettier_cmd.rs +221 -0
  182. package/temp/rtk/src/prisma_cmd.rs +482 -0
  183. package/temp/rtk/src/psql_cmd.rs +382 -0
  184. package/temp/rtk/src/pytest_cmd.rs +384 -0
  185. package/temp/rtk/src/read.rs +217 -0
  186. package/temp/rtk/src/rewrite_cmd.rs +50 -0
  187. package/temp/rtk/src/ruff_cmd.rs +402 -0
  188. package/temp/rtk/src/runner.rs +271 -0
  189. package/temp/rtk/src/summary.rs +297 -0
  190. package/temp/rtk/src/tee.rs +405 -0
  191. package/temp/rtk/src/telemetry.rs +248 -0
  192. package/temp/rtk/src/toml_filter.rs +1655 -0
  193. package/temp/rtk/src/tracking.rs +1416 -0
  194. package/temp/rtk/src/tree.rs +209 -0
  195. package/temp/rtk/src/tsc_cmd.rs +259 -0
  196. package/temp/rtk/src/utils.rs +432 -0
  197. package/temp/rtk/src/verify_cmd.rs +47 -0
  198. package/temp/rtk/src/vitest_cmd.rs +385 -0
  199. package/temp/rtk/src/wc_cmd.rs +401 -0
  200. package/temp/rtk/src/wget_cmd.rs +260 -0
  201. package/temp/rtk/tests/fixtures/dotnet/build_failed.txt +11 -0
  202. package/temp/rtk/tests/fixtures/dotnet/format_changes.json +31 -0
  203. package/temp/rtk/tests/fixtures/dotnet/format_empty.json +1 -0
  204. package/temp/rtk/tests/fixtures/dotnet/format_success.json +12 -0
  205. package/temp/rtk/tests/fixtures/dotnet/test_failed.txt +18 -0
@@ -0,0 +1,2012 @@
1
+ use crate::tracking;
2
+ use anyhow::{Context, Result};
3
+ use std::ffi::OsString;
4
+ use std::process::Command;
5
+
6
+ #[derive(Debug, Clone)]
7
+ pub enum GitCommand {
8
+ Diff,
9
+ Log,
10
+ Status,
11
+ Show,
12
+ Add,
13
+ Commit,
14
+ Push,
15
+ Pull,
16
+ Branch,
17
+ Fetch,
18
+ Stash { subcommand: Option<String> },
19
+ Worktree,
20
+ }
21
+
22
+ /// Create a git Command with global options (e.g. -C, -c, --git-dir, --work-tree)
23
+ /// prepended before any subcommand arguments.
24
+ fn git_cmd(global_args: &[String]) -> Command {
25
+ let mut cmd = Command::new("git");
26
+ for arg in global_args {
27
+ cmd.arg(arg);
28
+ }
29
+ cmd
30
+ }
31
+
32
+ pub fn run(
33
+ cmd: GitCommand,
34
+ args: &[String],
35
+ max_lines: Option<usize>,
36
+ verbose: u8,
37
+ global_args: &[String],
38
+ ) -> Result<()> {
39
+ match cmd {
40
+ GitCommand::Diff => run_diff(args, max_lines, verbose, global_args),
41
+ GitCommand::Log => run_log(args, max_lines, verbose, global_args),
42
+ GitCommand::Status => run_status(args, verbose, global_args),
43
+ GitCommand::Show => run_show(args, max_lines, verbose, global_args),
44
+ GitCommand::Add => run_add(args, verbose, global_args),
45
+ GitCommand::Commit => run_commit(args, verbose, global_args),
46
+ GitCommand::Push => run_push(args, verbose, global_args),
47
+ GitCommand::Pull => run_pull(args, verbose, global_args),
48
+ GitCommand::Branch => run_branch(args, verbose, global_args),
49
+ GitCommand::Fetch => run_fetch(args, verbose, global_args),
50
+ GitCommand::Stash { subcommand } => {
51
+ run_stash(subcommand.as_deref(), args, verbose, global_args)
52
+ }
53
+ GitCommand::Worktree => run_worktree(args, verbose, global_args),
54
+ }
55
+ }
56
+
57
+ fn run_diff(
58
+ args: &[String],
59
+ max_lines: Option<usize>,
60
+ verbose: u8,
61
+ global_args: &[String],
62
+ ) -> Result<()> {
63
+ let timer = tracking::TimedExecution::start();
64
+
65
+ // Check if user wants stat output
66
+ let wants_stat = args
67
+ .iter()
68
+ .any(|arg| arg == "--stat" || arg == "--numstat" || arg == "--shortstat");
69
+
70
+ // Check if user wants compact diff (default RTK behavior)
71
+ let wants_compact = !args.iter().any(|arg| arg == "--no-compact");
72
+
73
+ if wants_stat || !wants_compact {
74
+ // User wants stat or explicitly no compacting - pass through directly
75
+ let mut cmd = git_cmd(global_args);
76
+ cmd.arg("diff");
77
+ for arg in args {
78
+ cmd.arg(arg);
79
+ }
80
+
81
+ let output = cmd.output().context("Failed to run git diff")?;
82
+
83
+ if !output.status.success() {
84
+ let stderr = String::from_utf8_lossy(&output.stderr);
85
+ eprintln!("{}", stderr);
86
+ std::process::exit(output.status.code().unwrap_or(1));
87
+ }
88
+
89
+ let stdout = String::from_utf8_lossy(&output.stdout);
90
+ println!("{}", stdout.trim());
91
+
92
+ timer.track(
93
+ &format!("git diff {}", args.join(" ")),
94
+ &format!("rtk git diff {} (passthrough)", args.join(" ")),
95
+ &stdout,
96
+ &stdout,
97
+ );
98
+
99
+ return Ok(());
100
+ }
101
+
102
+ // Default RTK behavior: stat first, then compacted diff
103
+ let mut cmd = git_cmd(global_args);
104
+ cmd.arg("diff").arg("--stat");
105
+
106
+ for arg in args {
107
+ cmd.arg(arg);
108
+ }
109
+
110
+ let output = cmd.output().context("Failed to run git diff")?;
111
+ let stat_stdout = String::from_utf8_lossy(&output.stdout);
112
+
113
+ if verbose > 0 {
114
+ eprintln!("Git diff summary:");
115
+ }
116
+
117
+ // Print stat summary first
118
+ println!("{}", stat_stdout.trim());
119
+
120
+ // Now get actual diff but compact it
121
+ let mut diff_cmd = git_cmd(global_args);
122
+ diff_cmd.arg("diff");
123
+ for arg in args {
124
+ diff_cmd.arg(arg);
125
+ }
126
+
127
+ let diff_output = diff_cmd.output().context("Failed to run git diff")?;
128
+ let diff_stdout = String::from_utf8_lossy(&diff_output.stdout);
129
+
130
+ let mut final_output = stat_stdout.to_string();
131
+ if !diff_stdout.is_empty() {
132
+ println!("\n--- Changes ---");
133
+ let compacted = compact_diff(&diff_stdout, max_lines.unwrap_or(500));
134
+ println!("{}", compacted);
135
+ final_output.push_str("\n--- Changes ---\n");
136
+ final_output.push_str(&compacted);
137
+ }
138
+
139
+ timer.track(
140
+ &format!("git diff {}", args.join(" ")),
141
+ &format!("rtk git diff {}", args.join(" ")),
142
+ &format!("{}\n{}", stat_stdout, diff_stdout),
143
+ &final_output,
144
+ );
145
+
146
+ Ok(())
147
+ }
148
+
149
+ fn run_show(
150
+ args: &[String],
151
+ max_lines: Option<usize>,
152
+ verbose: u8,
153
+ global_args: &[String],
154
+ ) -> Result<()> {
155
+ let timer = tracking::TimedExecution::start();
156
+
157
+ // If user wants --stat or --format only, pass through
158
+ let wants_stat_only = args
159
+ .iter()
160
+ .any(|arg| arg == "--stat" || arg == "--numstat" || arg == "--shortstat");
161
+
162
+ let wants_format = args
163
+ .iter()
164
+ .any(|arg| arg.starts_with("--pretty") || arg.starts_with("--format"));
165
+
166
+ // `git show rev:path` prints a blob, not a commit diff. In this mode we should
167
+ // pass through directly to avoid duplicated output from compact-show steps.
168
+ let wants_blob_show = args.iter().any(|arg| is_blob_show_arg(arg));
169
+
170
+ if wants_stat_only || wants_format || wants_blob_show {
171
+ let mut cmd = git_cmd(global_args);
172
+ cmd.arg("show");
173
+ for arg in args {
174
+ cmd.arg(arg);
175
+ }
176
+ let output = cmd.output().context("Failed to run git show")?;
177
+ if !output.status.success() {
178
+ let stderr = String::from_utf8_lossy(&output.stderr);
179
+ eprintln!("{}", stderr);
180
+ std::process::exit(output.status.code().unwrap_or(1));
181
+ }
182
+ let stdout = String::from_utf8_lossy(&output.stdout);
183
+ if wants_blob_show {
184
+ print!("{}", stdout);
185
+ } else {
186
+ println!("{}", stdout.trim());
187
+ }
188
+
189
+ timer.track(
190
+ &format!("git show {}", args.join(" ")),
191
+ &format!("rtk git show {} (passthrough)", args.join(" ")),
192
+ &stdout,
193
+ &stdout,
194
+ );
195
+
196
+ return Ok(());
197
+ }
198
+
199
+ // Get raw output for tracking
200
+ let mut raw_cmd = git_cmd(global_args);
201
+ raw_cmd.arg("show");
202
+ for arg in args {
203
+ raw_cmd.arg(arg);
204
+ }
205
+ let raw_output = raw_cmd
206
+ .output()
207
+ .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
208
+ .unwrap_or_default();
209
+
210
+ // Step 1: one-line commit summary
211
+ let mut summary_cmd = git_cmd(global_args);
212
+ summary_cmd.args(["show", "--no-patch", "--pretty=format:%h %s (%ar) <%an>"]);
213
+ for arg in args {
214
+ summary_cmd.arg(arg);
215
+ }
216
+ let summary_output = summary_cmd.output().context("Failed to run git show")?;
217
+ if !summary_output.status.success() {
218
+ let stderr = String::from_utf8_lossy(&summary_output.stderr);
219
+ eprintln!("{}", stderr);
220
+ std::process::exit(summary_output.status.code().unwrap_or(1));
221
+ }
222
+ let summary = String::from_utf8_lossy(&summary_output.stdout);
223
+ println!("{}", summary.trim());
224
+
225
+ // Step 2: --stat summary
226
+ let mut stat_cmd = git_cmd(global_args);
227
+ stat_cmd.args(["show", "--stat", "--pretty=format:"]);
228
+ for arg in args {
229
+ stat_cmd.arg(arg);
230
+ }
231
+ let stat_output = stat_cmd.output().context("Failed to run git show --stat")?;
232
+ let stat_stdout = String::from_utf8_lossy(&stat_output.stdout);
233
+ let stat_text = stat_stdout.trim();
234
+ if !stat_text.is_empty() {
235
+ println!("{}", stat_text);
236
+ }
237
+
238
+ // Step 3: compacted diff
239
+ let mut diff_cmd = git_cmd(global_args);
240
+ diff_cmd.args(["show", "--pretty=format:"]);
241
+ for arg in args {
242
+ diff_cmd.arg(arg);
243
+ }
244
+ let diff_output = diff_cmd.output().context("Failed to run git show (diff)")?;
245
+ let diff_stdout = String::from_utf8_lossy(&diff_output.stdout);
246
+ let diff_text = diff_stdout.trim();
247
+
248
+ let mut final_output = summary.to_string();
249
+ if !diff_text.is_empty() {
250
+ if verbose > 0 {
251
+ println!("\n--- Changes ---");
252
+ }
253
+ let compacted = compact_diff(diff_text, max_lines.unwrap_or(500));
254
+ println!("{}", compacted);
255
+ final_output.push_str(&format!("\n{}", compacted));
256
+ }
257
+
258
+ timer.track(
259
+ &format!("git show {}", args.join(" ")),
260
+ &format!("rtk git show {}", args.join(" ")),
261
+ &raw_output,
262
+ &final_output,
263
+ );
264
+
265
+ Ok(())
266
+ }
267
+
268
+ fn is_blob_show_arg(arg: &str) -> bool {
269
+ // Detect `rev:path` style arguments while ignoring flags like `--pretty=format:...`.
270
+ !arg.starts_with('-') && arg.contains(':')
271
+ }
272
+
273
+ pub(crate) fn compact_diff(diff: &str, max_lines: usize) -> String {
274
+ let mut result = Vec::new();
275
+ let mut current_file = String::new();
276
+ let mut added = 0;
277
+ let mut removed = 0;
278
+ let mut in_hunk = false;
279
+ let mut hunk_lines = 0;
280
+ let max_hunk_lines = 30;
281
+
282
+ for line in diff.lines() {
283
+ if line.starts_with("diff --git") {
284
+ // New file
285
+ if !current_file.is_empty() && (added > 0 || removed > 0) {
286
+ result.push(format!(" +{} -{}", added, removed));
287
+ }
288
+ current_file = line.split(" b/").nth(1).unwrap_or("unknown").to_string();
289
+ result.push(format!("\n📄 {}", current_file));
290
+ added = 0;
291
+ removed = 0;
292
+ in_hunk = false;
293
+ } else if line.starts_with("@@") {
294
+ // New hunk
295
+ in_hunk = true;
296
+ hunk_lines = 0;
297
+ let hunk_info = line.split("@@").nth(1).unwrap_or("").trim();
298
+ result.push(format!(" @@ {} @@", hunk_info));
299
+ } else if in_hunk {
300
+ if line.starts_with('+') && !line.starts_with("+++") {
301
+ added += 1;
302
+ if hunk_lines < max_hunk_lines {
303
+ result.push(format!(" {}", line));
304
+ hunk_lines += 1;
305
+ }
306
+ } else if line.starts_with('-') && !line.starts_with("---") {
307
+ removed += 1;
308
+ if hunk_lines < max_hunk_lines {
309
+ result.push(format!(" {}", line));
310
+ hunk_lines += 1;
311
+ }
312
+ } else if hunk_lines < max_hunk_lines && !line.starts_with("\\") {
313
+ // Context line
314
+ if hunk_lines > 0 {
315
+ result.push(format!(" {}", line));
316
+ hunk_lines += 1;
317
+ }
318
+ }
319
+
320
+ if hunk_lines == max_hunk_lines {
321
+ result.push(" ... (truncated)".to_string());
322
+ hunk_lines += 1;
323
+ }
324
+ }
325
+
326
+ if result.len() >= max_lines {
327
+ result.push("\n... (more changes truncated)".to_string());
328
+ break;
329
+ }
330
+ }
331
+
332
+ if !current_file.is_empty() && (added > 0 || removed > 0) {
333
+ result.push(format!(" +{} -{}", added, removed));
334
+ }
335
+
336
+ result.join("\n")
337
+ }
338
+
339
+ fn run_log(
340
+ args: &[String],
341
+ _max_lines: Option<usize>,
342
+ verbose: u8,
343
+ global_args: &[String],
344
+ ) -> Result<()> {
345
+ let timer = tracking::TimedExecution::start();
346
+
347
+ let mut cmd = git_cmd(global_args);
348
+ cmd.arg("log");
349
+
350
+ // Check if user provided format flags
351
+ let has_format_flag = args.iter().any(|arg| {
352
+ arg.starts_with("--oneline") || arg.starts_with("--pretty") || arg.starts_with("--format")
353
+ });
354
+
355
+ // Check if user provided limit flag (-N, -n N, --max-count=N, --max-count N)
356
+ let has_limit_flag = args.iter().any(|arg| {
357
+ (arg.starts_with('-') && arg.chars().nth(1).map_or(false, |c| c.is_ascii_digit()))
358
+ || arg == "-n"
359
+ || arg.starts_with("--max-count")
360
+ });
361
+
362
+ // Apply RTK defaults only if user didn't specify them
363
+ if !has_format_flag {
364
+ cmd.args(["--pretty=format:%h %s (%ar) <%an>"]);
365
+ }
366
+
367
+ // Determine limit: respect user's explicit -N flag, use sensible defaults otherwise
368
+ let (limit, user_set_limit) = if has_limit_flag {
369
+ // User explicitly passed -N / -n N / --max-count=N → respect their choice
370
+ let n = parse_user_limit(args).unwrap_or(10);
371
+ (n, true)
372
+ } else if has_format_flag {
373
+ // --oneline / --pretty without -N: user wants compact output, allow more
374
+ cmd.arg("-50");
375
+ (50, false)
376
+ } else {
377
+ // No flags at all: default to 10
378
+ cmd.arg("-10");
379
+ (10, false)
380
+ };
381
+
382
+ // Only add --no-merges if user didn't explicitly request merge commits
383
+ let wants_merges = args
384
+ .iter()
385
+ .any(|arg| arg == "--merges" || arg == "--min-parents=2");
386
+ if !wants_merges {
387
+ cmd.arg("--no-merges");
388
+ }
389
+
390
+ // Pass all user arguments
391
+ for arg in args {
392
+ cmd.arg(arg);
393
+ }
394
+
395
+ let output = cmd.output().context("Failed to run git log")?;
396
+
397
+ if !output.status.success() {
398
+ let stderr = String::from_utf8_lossy(&output.stderr);
399
+ eprintln!("{}", stderr);
400
+ // Propagate git's exit code
401
+ std::process::exit(output.status.code().unwrap_or(1));
402
+ }
403
+
404
+ let stdout = String::from_utf8_lossy(&output.stdout);
405
+
406
+ if verbose > 0 {
407
+ eprintln!("Git log output:");
408
+ }
409
+
410
+ // Post-process: truncate long messages, cap lines only if RTK set the default
411
+ let filtered = filter_log_output(&stdout, limit, user_set_limit);
412
+ println!("{}", filtered);
413
+
414
+ timer.track(
415
+ &format!("git log {}", args.join(" ")),
416
+ &format!("rtk git log {}", args.join(" ")),
417
+ &stdout,
418
+ &filtered,
419
+ );
420
+
421
+ Ok(())
422
+ }
423
+
424
+ /// Filter git log output: truncate long messages, cap lines
425
+ /// Parse the user-specified limit from git log args.
426
+ /// Handles: -20, -n 20, --max-count=20, --max-count 20
427
+ fn parse_user_limit(args: &[String]) -> Option<usize> {
428
+ let mut iter = args.iter();
429
+ while let Some(arg) = iter.next() {
430
+ // -20 (combined digit form)
431
+ if arg.starts_with('-')
432
+ && arg.len() > 1
433
+ && arg.chars().nth(1).map_or(false, |c| c.is_ascii_digit())
434
+ {
435
+ if let Ok(n) = arg[1..].parse::<usize>() {
436
+ return Some(n);
437
+ }
438
+ }
439
+ // -n 20 (two-token form)
440
+ if arg == "-n" {
441
+ if let Some(next) = iter.next() {
442
+ if let Ok(n) = next.parse::<usize>() {
443
+ return Some(n);
444
+ }
445
+ }
446
+ }
447
+ // --max-count=20
448
+ if let Some(rest) = arg.strip_prefix("--max-count=") {
449
+ if let Ok(n) = rest.parse::<usize>() {
450
+ return Some(n);
451
+ }
452
+ }
453
+ // --max-count 20 (two-token form)
454
+ if arg == "--max-count" {
455
+ if let Some(next) = iter.next() {
456
+ if let Ok(n) = next.parse::<usize>() {
457
+ return Some(n);
458
+ }
459
+ }
460
+ }
461
+ }
462
+ None
463
+ }
464
+
465
+ /// When `user_set_limit` is true, the user explicitly passed `-N` to git log,
466
+ /// so we skip line capping (git already returns exactly N commits) and use a
467
+ /// wider truncation threshold (120 chars) to preserve commit context that LLMs
468
+ /// need for rebase/squash operations.
469
+ fn filter_log_output(output: &str, limit: usize, user_set_limit: bool) -> String {
470
+ let lines: Vec<&str> = output.lines().collect();
471
+
472
+ let truncate_width = if user_set_limit { 120 } else { 80 };
473
+
474
+ let iter = lines.iter();
475
+ let capped: Vec<String> = if user_set_limit {
476
+ // User chose the limit → git already returned the right number of commits
477
+ iter.map(|line| truncate_line(line, truncate_width))
478
+ .collect()
479
+ } else {
480
+ // RTK default → cap output lines
481
+ iter.take(limit)
482
+ .map(|line| truncate_line(line, truncate_width))
483
+ .collect()
484
+ };
485
+
486
+ capped.join("\n").trim().to_string()
487
+ }
488
+
489
+ /// Truncate a single line to `width` characters, appending "..." if needed
490
+ fn truncate_line(line: &str, width: usize) -> String {
491
+ if line.chars().count() > width {
492
+ let truncated: String = line.chars().take(width - 3).collect();
493
+ format!("{}...", truncated)
494
+ } else {
495
+ line.to_string()
496
+ }
497
+ }
498
+
499
+ /// Format porcelain output into compact RTK status display
500
+ fn format_status_output(porcelain: &str) -> String {
501
+ let lines: Vec<&str> = porcelain.lines().collect();
502
+
503
+ if lines.is_empty() {
504
+ return "Clean working tree".to_string();
505
+ }
506
+
507
+ let mut output = String::new();
508
+
509
+ // Parse branch info
510
+ if let Some(branch_line) = lines.first() {
511
+ if branch_line.starts_with("##") {
512
+ let branch = branch_line.trim_start_matches("## ");
513
+ output.push_str(&format!("📌 {}\n", branch));
514
+ }
515
+ }
516
+
517
+ // Count changes by type
518
+ let mut staged = 0;
519
+ let mut modified = 0;
520
+ let mut untracked = 0;
521
+ let mut conflicts = 0;
522
+
523
+ let mut staged_files = Vec::new();
524
+ let mut modified_files = Vec::new();
525
+ let mut untracked_files = Vec::new();
526
+
527
+ for line in lines.iter().skip(1) {
528
+ if line.len() < 3 {
529
+ continue;
530
+ }
531
+ let status = line.get(0..2).unwrap_or(" ");
532
+ let file = line.get(3..).unwrap_or("");
533
+
534
+ match status.chars().next().unwrap_or(' ') {
535
+ 'M' | 'A' | 'D' | 'R' | 'C' => {
536
+ staged += 1;
537
+ staged_files.push(file);
538
+ }
539
+ 'U' => conflicts += 1,
540
+ _ => {}
541
+ }
542
+
543
+ match status.chars().nth(1).unwrap_or(' ') {
544
+ 'M' | 'D' => {
545
+ modified += 1;
546
+ modified_files.push(file);
547
+ }
548
+ _ => {}
549
+ }
550
+
551
+ if status == "??" {
552
+ untracked += 1;
553
+ untracked_files.push(file);
554
+ }
555
+ }
556
+
557
+ // Build summary
558
+ if staged > 0 {
559
+ output.push_str(&format!("✅ Staged: {} files\n", staged));
560
+ for f in staged_files.iter().take(5) {
561
+ output.push_str(&format!(" {}\n", f));
562
+ }
563
+ if staged_files.len() > 5 {
564
+ output.push_str(&format!(" ... +{} more\n", staged_files.len() - 5));
565
+ }
566
+ }
567
+
568
+ if modified > 0 {
569
+ output.push_str(&format!("📝 Modified: {} files\n", modified));
570
+ for f in modified_files.iter().take(5) {
571
+ output.push_str(&format!(" {}\n", f));
572
+ }
573
+ if modified_files.len() > 5 {
574
+ output.push_str(&format!(" ... +{} more\n", modified_files.len() - 5));
575
+ }
576
+ }
577
+
578
+ if untracked > 0 {
579
+ output.push_str(&format!("❓ Untracked: {} files\n", untracked));
580
+ for f in untracked_files.iter().take(3) {
581
+ output.push_str(&format!(" {}\n", f));
582
+ }
583
+ if untracked_files.len() > 3 {
584
+ output.push_str(&format!(" ... +{} more\n", untracked_files.len() - 3));
585
+ }
586
+ }
587
+
588
+ if conflicts > 0 {
589
+ output.push_str(&format!("⚠️ Conflicts: {} files\n", conflicts));
590
+ }
591
+
592
+ output.trim_end().to_string()
593
+ }
594
+
595
+ /// Minimal filtering for git status with user-provided args
596
+ fn filter_status_with_args(output: &str) -> String {
597
+ let mut result = Vec::new();
598
+
599
+ for line in output.lines() {
600
+ let trimmed = line.trim();
601
+
602
+ // Skip empty lines
603
+ if trimmed.is_empty() {
604
+ continue;
605
+ }
606
+
607
+ // Skip git hints - can appear at start or within line
608
+ if trimmed.starts_with("(use \"git")
609
+ || trimmed.starts_with("(create/copy files")
610
+ || trimmed.contains("(use \"git add")
611
+ || trimmed.contains("(use \"git restore")
612
+ {
613
+ continue;
614
+ }
615
+
616
+ // Special case: clean working tree
617
+ if trimmed.contains("nothing to commit") && trimmed.contains("working tree clean") {
618
+ result.push(trimmed.to_string());
619
+ break;
620
+ }
621
+
622
+ result.push(line.to_string());
623
+ }
624
+
625
+ if result.is_empty() {
626
+ "ok ✓".to_string()
627
+ } else {
628
+ result.join("\n")
629
+ }
630
+ }
631
+
632
+ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> {
633
+ let timer = tracking::TimedExecution::start();
634
+
635
+ // If user provided flags, apply minimal filtering
636
+ if !args.is_empty() {
637
+ let output = git_cmd(global_args)
638
+ .arg("status")
639
+ .args(args)
640
+ .output()
641
+ .context("Failed to run git status")?;
642
+
643
+ let stdout = String::from_utf8_lossy(&output.stdout);
644
+ let stderr = String::from_utf8_lossy(&output.stderr);
645
+
646
+ if verbose > 0 || !stderr.is_empty() {
647
+ eprint!("{}", stderr);
648
+ }
649
+
650
+ // Apply minimal filtering: strip ANSI, remove hints, empty lines
651
+ let filtered = filter_status_with_args(&stdout);
652
+ print!("{}", filtered);
653
+
654
+ timer.track(
655
+ &format!("git status {}", args.join(" ")),
656
+ &format!("rtk git status {}", args.join(" ")),
657
+ &stdout,
658
+ &filtered,
659
+ );
660
+
661
+ return Ok(());
662
+ }
663
+
664
+ // Default RTK compact mode (no args provided)
665
+ // Get raw git status for tracking
666
+ let raw_output = git_cmd(global_args)
667
+ .args(["status"])
668
+ .output()
669
+ .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
670
+ .unwrap_or_default();
671
+
672
+ let output = git_cmd(global_args)
673
+ .args(["status", "--porcelain", "-b"])
674
+ .output()
675
+ .context("Failed to run git status")?;
676
+
677
+ let stdout = String::from_utf8_lossy(&output.stdout);
678
+ let stderr = String::from_utf8_lossy(&output.stderr);
679
+
680
+ if !stderr.is_empty() && stderr.contains("not a git repository") {
681
+ let message = "Not a git repository".to_string();
682
+ eprintln!("{}", message);
683
+ timer.track("git status", "rtk git status", &raw_output, &message);
684
+ std::process::exit(output.status.code().unwrap_or(128));
685
+ }
686
+
687
+ let formatted = format_status_output(&stdout);
688
+
689
+ println!("{}", formatted);
690
+
691
+ // Track for statistics
692
+ timer.track("git status", "rtk git status", &raw_output, &formatted);
693
+
694
+ Ok(())
695
+ }
696
+
697
+ fn run_add(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> {
698
+ let timer = tracking::TimedExecution::start();
699
+
700
+ let mut cmd = git_cmd(global_args);
701
+ cmd.arg("add");
702
+
703
+ // Pass all arguments directly to git (flags like -A, -p, --all, etc.)
704
+ if args.is_empty() {
705
+ cmd.arg(".");
706
+ } else {
707
+ for arg in args {
708
+ cmd.arg(arg);
709
+ }
710
+ }
711
+
712
+ let output = cmd.output().context("Failed to run git add")?;
713
+
714
+ if verbose > 0 {
715
+ eprintln!("git add executed");
716
+ }
717
+
718
+ let raw_output = format!(
719
+ "{}\n{}",
720
+ String::from_utf8_lossy(&output.stdout),
721
+ String::from_utf8_lossy(&output.stderr)
722
+ );
723
+
724
+ if output.status.success() {
725
+ // Count what was added
726
+ let status_output = git_cmd(global_args)
727
+ .args(["diff", "--cached", "--stat", "--shortstat"])
728
+ .output()
729
+ .context("Failed to check staged files")?;
730
+
731
+ let stat = String::from_utf8_lossy(&status_output.stdout);
732
+ let compact = if stat.trim().is_empty() {
733
+ "ok (nothing to add)".to_string()
734
+ } else {
735
+ // Parse "1 file changed, 5 insertions(+)" format
736
+ let short = stat.lines().last().unwrap_or("").trim();
737
+ if short.is_empty() {
738
+ "ok ✓".to_string()
739
+ } else {
740
+ format!("ok ✓ {}", short)
741
+ }
742
+ };
743
+
744
+ println!("{}", compact);
745
+
746
+ timer.track(
747
+ &format!("git add {}", args.join(" ")),
748
+ &format!("rtk git add {}", args.join(" ")),
749
+ &raw_output,
750
+ &compact,
751
+ );
752
+ } else {
753
+ let stderr = String::from_utf8_lossy(&output.stderr);
754
+ let stdout = String::from_utf8_lossy(&output.stdout);
755
+ eprintln!("FAILED: git add");
756
+ if !stderr.trim().is_empty() {
757
+ eprintln!("{}", stderr);
758
+ }
759
+ if !stdout.trim().is_empty() {
760
+ eprintln!("{}", stdout);
761
+ }
762
+ // Propagate git's exit code
763
+ std::process::exit(output.status.code().unwrap_or(1));
764
+ }
765
+
766
+ Ok(())
767
+ }
768
+
769
+ fn build_commit_command(args: &[String], global_args: &[String]) -> Command {
770
+ let mut cmd = git_cmd(global_args);
771
+ cmd.arg("commit");
772
+ for arg in args {
773
+ cmd.arg(arg);
774
+ }
775
+ cmd
776
+ }
777
+
778
+ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> {
779
+ let timer = tracking::TimedExecution::start();
780
+
781
+ let original_cmd = format!("git commit {}", args.join(" "));
782
+
783
+ if verbose > 0 {
784
+ eprintln!("{}", original_cmd);
785
+ }
786
+
787
+ let output = build_commit_command(args, global_args)
788
+ .output()
789
+ .context("Failed to run git commit")?;
790
+
791
+ let stdout = String::from_utf8_lossy(&output.stdout);
792
+ let stderr = String::from_utf8_lossy(&output.stderr);
793
+ let raw_output = format!("{}\n{}", stdout, stderr);
794
+
795
+ if output.status.success() {
796
+ // Extract commit hash from output like "[main abc1234] message"
797
+ let compact = if let Some(line) = stdout.lines().next() {
798
+ if let Some(hash_start) = line.find(' ') {
799
+ let hash = line[1..hash_start].split(' ').last().unwrap_or("");
800
+ if !hash.is_empty() && hash.len() >= 7 {
801
+ format!("ok ✓ {}", &hash[..7.min(hash.len())])
802
+ } else {
803
+ "ok ✓".to_string()
804
+ }
805
+ } else {
806
+ "ok ✓".to_string()
807
+ }
808
+ } else {
809
+ "ok ✓".to_string()
810
+ };
811
+
812
+ println!("{}", compact);
813
+
814
+ timer.track(&original_cmd, "rtk git commit", &raw_output, &compact);
815
+ } else {
816
+ if stderr.contains("nothing to commit") || stdout.contains("nothing to commit") {
817
+ println!("ok (nothing to commit)");
818
+ timer.track(
819
+ &original_cmd,
820
+ "rtk git commit",
821
+ &raw_output,
822
+ "ok (nothing to commit)",
823
+ );
824
+ } else {
825
+ eprintln!("FAILED: git commit");
826
+ if !stderr.trim().is_empty() {
827
+ eprintln!("{}", stderr);
828
+ }
829
+ if !stdout.trim().is_empty() {
830
+ eprintln!("{}", stdout);
831
+ }
832
+ }
833
+ }
834
+
835
+ Ok(())
836
+ }
837
+
838
+ fn run_push(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> {
839
+ let timer = tracking::TimedExecution::start();
840
+
841
+ if verbose > 0 {
842
+ eprintln!("git push");
843
+ }
844
+
845
+ let mut cmd = git_cmd(global_args);
846
+ cmd.arg("push");
847
+ for arg in args {
848
+ cmd.arg(arg);
849
+ }
850
+
851
+ let output = cmd.output().context("Failed to run git push")?;
852
+
853
+ let stderr = String::from_utf8_lossy(&output.stderr);
854
+ let stdout = String::from_utf8_lossy(&output.stdout);
855
+ let raw = format!("{}{}", stdout, stderr);
856
+
857
+ if output.status.success() {
858
+ let compact = if stderr.contains("Everything up-to-date") {
859
+ "ok (up-to-date)".to_string()
860
+ } else {
861
+ let mut result = String::new();
862
+ for line in stderr.lines() {
863
+ if line.contains("->") {
864
+ let parts: Vec<&str> = line.split_whitespace().collect();
865
+ if parts.len() >= 3 {
866
+ result = format!("ok ✓ {}", parts[parts.len() - 1]);
867
+ break;
868
+ }
869
+ }
870
+ }
871
+ if !result.is_empty() {
872
+ result
873
+ } else {
874
+ "ok ✓".to_string()
875
+ }
876
+ };
877
+
878
+ println!("{}", compact);
879
+
880
+ timer.track(
881
+ &format!("git push {}", args.join(" ")),
882
+ &format!("rtk git push {}", args.join(" ")),
883
+ &raw,
884
+ &compact,
885
+ );
886
+ } else {
887
+ eprintln!("FAILED: git push");
888
+ if !stderr.trim().is_empty() {
889
+ eprintln!("{}", stderr);
890
+ }
891
+ if !stdout.trim().is_empty() {
892
+ eprintln!("{}", stdout);
893
+ }
894
+ std::process::exit(output.status.code().unwrap_or(1));
895
+ }
896
+
897
+ Ok(())
898
+ }
899
+
900
+ fn run_pull(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> {
901
+ let timer = tracking::TimedExecution::start();
902
+
903
+ if verbose > 0 {
904
+ eprintln!("git pull");
905
+ }
906
+
907
+ let mut cmd = git_cmd(global_args);
908
+ cmd.arg("pull");
909
+ for arg in args {
910
+ cmd.arg(arg);
911
+ }
912
+
913
+ let output = cmd.output().context("Failed to run git pull")?;
914
+
915
+ let stdout = String::from_utf8_lossy(&output.stdout);
916
+ let stderr = String::from_utf8_lossy(&output.stderr);
917
+ let raw_output = format!("{}\n{}", stdout, stderr);
918
+
919
+ if output.status.success() {
920
+ let compact =
921
+ if stdout.contains("Already up to date") || stdout.contains("Already up-to-date") {
922
+ "ok (up-to-date)".to_string()
923
+ } else {
924
+ // Count files changed
925
+ let mut files = 0;
926
+ let mut insertions = 0;
927
+ let mut deletions = 0;
928
+
929
+ for line in stdout.lines() {
930
+ if line.contains("file") && line.contains("changed") {
931
+ // Parse "3 files changed, 10 insertions(+), 2 deletions(-)"
932
+ for part in line.split(',') {
933
+ let part = part.trim();
934
+ if part.contains("file") {
935
+ files = part
936
+ .split_whitespace()
937
+ .next()
938
+ .and_then(|n| n.parse().ok())
939
+ .unwrap_or(0);
940
+ } else if part.contains("insertion") {
941
+ insertions = part
942
+ .split_whitespace()
943
+ .next()
944
+ .and_then(|n| n.parse().ok())
945
+ .unwrap_or(0);
946
+ } else if part.contains("deletion") {
947
+ deletions = part
948
+ .split_whitespace()
949
+ .next()
950
+ .and_then(|n| n.parse().ok())
951
+ .unwrap_or(0);
952
+ }
953
+ }
954
+ }
955
+ }
956
+
957
+ if files > 0 {
958
+ format!("ok ✓ {} files +{} -{}", files, insertions, deletions)
959
+ } else {
960
+ "ok ✓".to_string()
961
+ }
962
+ };
963
+
964
+ println!("{}", compact);
965
+
966
+ timer.track(
967
+ &format!("git pull {}", args.join(" ")),
968
+ &format!("rtk git pull {}", args.join(" ")),
969
+ &raw_output,
970
+ &compact,
971
+ );
972
+ } else {
973
+ eprintln!("FAILED: git pull");
974
+ if !stderr.trim().is_empty() {
975
+ eprintln!("{}", stderr);
976
+ }
977
+ if !stdout.trim().is_empty() {
978
+ eprintln!("{}", stdout);
979
+ }
980
+ std::process::exit(output.status.code().unwrap_or(1));
981
+ }
982
+
983
+ Ok(())
984
+ }
985
+
986
+ fn run_branch(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> {
987
+ let timer = tracking::TimedExecution::start();
988
+
989
+ if verbose > 0 {
990
+ eprintln!("git branch");
991
+ }
992
+
993
+ // Detect write operations: delete, rename, copy
994
+ let has_action_flag = args
995
+ .iter()
996
+ .any(|a| a == "-d" || a == "-D" || a == "-m" || a == "-M" || a == "-c" || a == "-C");
997
+
998
+ // Detect list-mode flags
999
+ let has_list_flag = args.iter().any(|a| {
1000
+ a == "-a"
1001
+ || a == "--all"
1002
+ || a == "-r"
1003
+ || a == "--remotes"
1004
+ || a == "--list"
1005
+ || a == "--merged"
1006
+ || a == "--no-merged"
1007
+ || a == "--contains"
1008
+ || a == "--no-contains"
1009
+ });
1010
+
1011
+ // Detect positional arguments (not flags) — indicates branch creation
1012
+ let has_positional_arg = args.iter().any(|a| !a.starts_with('-'));
1013
+
1014
+ // Write operation: action flags, or positional args without list flags (= branch creation)
1015
+ if has_action_flag || (has_positional_arg && !has_list_flag) {
1016
+ let mut cmd = git_cmd(global_args);
1017
+ cmd.arg("branch");
1018
+ for arg in args {
1019
+ cmd.arg(arg);
1020
+ }
1021
+ let output = cmd.output().context("Failed to run git branch")?;
1022
+ let stdout = String::from_utf8_lossy(&output.stdout);
1023
+ let stderr = String::from_utf8_lossy(&output.stderr);
1024
+ let combined = format!("{}{}", stdout, stderr);
1025
+
1026
+ let msg = if output.status.success() {
1027
+ "ok ✓"
1028
+ } else {
1029
+ &combined
1030
+ };
1031
+
1032
+ timer.track(
1033
+ &format!("git branch {}", args.join(" ")),
1034
+ &format!("rtk git branch {}", args.join(" ")),
1035
+ &combined,
1036
+ msg,
1037
+ );
1038
+
1039
+ if output.status.success() {
1040
+ println!("ok ✓");
1041
+ } else {
1042
+ eprintln!("FAILED: git branch {}", args.join(" "));
1043
+ if !stderr.trim().is_empty() {
1044
+ eprintln!("{}", stderr);
1045
+ }
1046
+ if !stdout.trim().is_empty() {
1047
+ eprintln!("{}", stdout);
1048
+ }
1049
+ std::process::exit(output.status.code().unwrap_or(1));
1050
+ }
1051
+ return Ok(());
1052
+ }
1053
+
1054
+ // List mode: show compact branch list
1055
+ let mut cmd = git_cmd(global_args);
1056
+ cmd.arg("branch");
1057
+ if !has_list_flag {
1058
+ cmd.arg("-a");
1059
+ }
1060
+ cmd.arg("--no-color");
1061
+ for arg in args {
1062
+ cmd.arg(arg);
1063
+ }
1064
+
1065
+ let output = cmd.output().context("Failed to run git branch")?;
1066
+ let stdout = String::from_utf8_lossy(&output.stdout);
1067
+ let raw = stdout.to_string();
1068
+
1069
+ let filtered = filter_branch_output(&stdout);
1070
+ println!("{}", filtered);
1071
+
1072
+ timer.track(
1073
+ &format!("git branch {}", args.join(" ")),
1074
+ &format!("rtk git branch {}", args.join(" ")),
1075
+ &raw,
1076
+ &filtered,
1077
+ );
1078
+
1079
+ Ok(())
1080
+ }
1081
+
1082
+ fn filter_branch_output(output: &str) -> String {
1083
+ let mut current = String::new();
1084
+ let mut local: Vec<String> = Vec::new();
1085
+ let mut remote: Vec<String> = Vec::new();
1086
+
1087
+ for line in output.lines() {
1088
+ let line = line.trim();
1089
+ if line.is_empty() {
1090
+ continue;
1091
+ }
1092
+
1093
+ if let Some(branch) = line.strip_prefix("* ") {
1094
+ current = branch.to_string();
1095
+ } else if line.starts_with("remotes/origin/") {
1096
+ let branch = line.strip_prefix("remotes/origin/").unwrap_or(line);
1097
+ // Skip HEAD pointer
1098
+ if branch.starts_with("HEAD ") {
1099
+ continue;
1100
+ }
1101
+ remote.push(branch.to_string());
1102
+ } else {
1103
+ local.push(line.to_string());
1104
+ }
1105
+ }
1106
+
1107
+ let mut result = Vec::new();
1108
+ result.push(format!("* {}", current));
1109
+
1110
+ if !local.is_empty() {
1111
+ for b in &local {
1112
+ result.push(format!(" {}", b));
1113
+ }
1114
+ }
1115
+
1116
+ if !remote.is_empty() {
1117
+ // Filter out remotes that already exist locally
1118
+ let remote_only: Vec<&String> = remote
1119
+ .iter()
1120
+ .filter(|r| *r != &current && !local.contains(r))
1121
+ .collect();
1122
+ if !remote_only.is_empty() {
1123
+ result.push(format!(" remote-only ({}):", remote_only.len()));
1124
+ for b in remote_only.iter().take(10) {
1125
+ result.push(format!(" {}", b));
1126
+ }
1127
+ if remote_only.len() > 10 {
1128
+ result.push(format!(" ... +{} more", remote_only.len() - 10));
1129
+ }
1130
+ }
1131
+ }
1132
+
1133
+ result.join("\n")
1134
+ }
1135
+
1136
+ fn run_fetch(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> {
1137
+ let timer = tracking::TimedExecution::start();
1138
+
1139
+ if verbose > 0 {
1140
+ eprintln!("git fetch");
1141
+ }
1142
+
1143
+ let mut cmd = git_cmd(global_args);
1144
+ cmd.arg("fetch");
1145
+ for arg in args {
1146
+ cmd.arg(arg);
1147
+ }
1148
+
1149
+ let output = cmd.output().context("Failed to run git fetch")?;
1150
+ let stdout = String::from_utf8_lossy(&output.stdout);
1151
+ let stderr = String::from_utf8_lossy(&output.stderr);
1152
+ let raw = format!("{}{}", stdout, stderr);
1153
+
1154
+ if !output.status.success() {
1155
+ eprintln!("FAILED: git fetch");
1156
+ if !stderr.trim().is_empty() {
1157
+ eprintln!("{}", stderr);
1158
+ }
1159
+ std::process::exit(output.status.code().unwrap_or(1));
1160
+ }
1161
+
1162
+ // Count new refs from stderr (git fetch outputs to stderr)
1163
+ let new_refs: usize = stderr
1164
+ .lines()
1165
+ .filter(|l| l.contains("->") || l.contains("[new"))
1166
+ .count();
1167
+
1168
+ let msg = if new_refs > 0 {
1169
+ format!("ok fetched ({} new refs)", new_refs)
1170
+ } else {
1171
+ "ok fetched".to_string()
1172
+ };
1173
+
1174
+ println!("{}", msg);
1175
+ timer.track("git fetch", "rtk git fetch", &raw, &msg);
1176
+
1177
+ Ok(())
1178
+ }
1179
+
1180
+ fn run_stash(
1181
+ subcommand: Option<&str>,
1182
+ args: &[String],
1183
+ verbose: u8,
1184
+ global_args: &[String],
1185
+ ) -> Result<()> {
1186
+ let timer = tracking::TimedExecution::start();
1187
+
1188
+ if verbose > 0 {
1189
+ eprintln!("git stash {:?}", subcommand);
1190
+ }
1191
+
1192
+ match subcommand {
1193
+ Some("list") => {
1194
+ let output = git_cmd(global_args)
1195
+ .args(["stash", "list"])
1196
+ .output()
1197
+ .context("Failed to run git stash list")?;
1198
+ let stdout = String::from_utf8_lossy(&output.stdout);
1199
+ let raw = stdout.to_string();
1200
+
1201
+ if stdout.trim().is_empty() {
1202
+ let msg = "No stashes";
1203
+ println!("{}", msg);
1204
+ timer.track("git stash list", "rtk git stash list", &raw, msg);
1205
+ return Ok(());
1206
+ }
1207
+
1208
+ let filtered = filter_stash_list(&stdout);
1209
+ println!("{}", filtered);
1210
+ timer.track("git stash list", "rtk git stash list", &raw, &filtered);
1211
+ }
1212
+ Some("show") => {
1213
+ let mut cmd = git_cmd(global_args);
1214
+ cmd.args(["stash", "show", "-p"]);
1215
+ for arg in args {
1216
+ cmd.arg(arg);
1217
+ }
1218
+ let output = cmd.output().context("Failed to run git stash show")?;
1219
+ let stdout = String::from_utf8_lossy(&output.stdout);
1220
+ let raw = stdout.to_string();
1221
+
1222
+ let filtered = if stdout.trim().is_empty() {
1223
+ let msg = "Empty stash";
1224
+ println!("{}", msg);
1225
+ msg.to_string()
1226
+ } else {
1227
+ let compacted = compact_diff(&stdout, 100);
1228
+ println!("{}", compacted);
1229
+ compacted
1230
+ };
1231
+
1232
+ timer.track("git stash show", "rtk git stash show", &raw, &filtered);
1233
+ }
1234
+ Some("pop") | Some("apply") | Some("drop") | Some("push") => {
1235
+ let sub = subcommand.unwrap();
1236
+ let mut cmd = git_cmd(global_args);
1237
+ cmd.args(["stash", sub]);
1238
+ for arg in args {
1239
+ cmd.arg(arg);
1240
+ }
1241
+ let output = cmd.output().context("Failed to run git stash")?;
1242
+ let stdout = String::from_utf8_lossy(&output.stdout);
1243
+ let stderr = String::from_utf8_lossy(&output.stderr);
1244
+ let combined = format!("{}{}", stdout, stderr);
1245
+
1246
+ let msg = if output.status.success() {
1247
+ let msg = format!("ok stash {}", sub);
1248
+ println!("{}", msg);
1249
+ msg
1250
+ } else {
1251
+ eprintln!("FAILED: git stash {}", sub);
1252
+ if !stderr.trim().is_empty() {
1253
+ eprintln!("{}", stderr);
1254
+ }
1255
+ combined.clone()
1256
+ };
1257
+
1258
+ timer.track(
1259
+ &format!("git stash {}", sub),
1260
+ &format!("rtk git stash {}", sub),
1261
+ &combined,
1262
+ &msg,
1263
+ );
1264
+
1265
+ if !output.status.success() {
1266
+ std::process::exit(output.status.code().unwrap_or(1));
1267
+ }
1268
+ }
1269
+ _ => {
1270
+ // Default: git stash (push)
1271
+ let mut cmd = git_cmd(global_args);
1272
+ cmd.arg("stash");
1273
+ for arg in args {
1274
+ cmd.arg(arg);
1275
+ }
1276
+ let output = cmd.output().context("Failed to run git stash")?;
1277
+ let stdout = String::from_utf8_lossy(&output.stdout);
1278
+ let stderr = String::from_utf8_lossy(&output.stderr);
1279
+ let combined = format!("{}{}", stdout, stderr);
1280
+
1281
+ let msg = if output.status.success() {
1282
+ if stdout.contains("No local changes") {
1283
+ let msg = "ok (nothing to stash)";
1284
+ println!("{}", msg);
1285
+ msg.to_string()
1286
+ } else {
1287
+ let msg = "ok stashed";
1288
+ println!("{}", msg);
1289
+ msg.to_string()
1290
+ }
1291
+ } else {
1292
+ eprintln!("FAILED: git stash");
1293
+ if !stderr.trim().is_empty() {
1294
+ eprintln!("{}", stderr);
1295
+ }
1296
+ combined.clone()
1297
+ };
1298
+
1299
+ timer.track("git stash", "rtk git stash", &combined, &msg);
1300
+
1301
+ if !output.status.success() {
1302
+ std::process::exit(output.status.code().unwrap_or(1));
1303
+ }
1304
+ }
1305
+ }
1306
+
1307
+ Ok(())
1308
+ }
1309
+
1310
+ fn filter_stash_list(output: &str) -> String {
1311
+ // Format: "stash@{0}: WIP on main: abc1234 commit message"
1312
+ let mut result = Vec::new();
1313
+ for line in output.lines() {
1314
+ if let Some(colon_pos) = line.find(": ") {
1315
+ let index = &line[..colon_pos];
1316
+ let rest = &line[colon_pos + 2..];
1317
+ // Compact: strip "WIP on branch:" prefix if present
1318
+ let message = if let Some(second_colon) = rest.find(": ") {
1319
+ rest[second_colon + 2..].trim()
1320
+ } else {
1321
+ rest.trim()
1322
+ };
1323
+ result.push(format!("{}: {}", index, message));
1324
+ } else {
1325
+ result.push(line.to_string());
1326
+ }
1327
+ }
1328
+ result.join("\n")
1329
+ }
1330
+
1331
+ fn run_worktree(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> {
1332
+ let timer = tracking::TimedExecution::start();
1333
+
1334
+ if verbose > 0 {
1335
+ eprintln!("git worktree list");
1336
+ }
1337
+
1338
+ // If args contain "add", "remove", "prune" etc., pass through
1339
+ let has_action = args.iter().any(|a| {
1340
+ a == "add" || a == "remove" || a == "prune" || a == "lock" || a == "unlock" || a == "move"
1341
+ });
1342
+
1343
+ if has_action {
1344
+ let mut cmd = git_cmd(global_args);
1345
+ cmd.arg("worktree");
1346
+ for arg in args {
1347
+ cmd.arg(arg);
1348
+ }
1349
+ let output = cmd.output().context("Failed to run git worktree")?;
1350
+ let stdout = String::from_utf8_lossy(&output.stdout);
1351
+ let stderr = String::from_utf8_lossy(&output.stderr);
1352
+ let combined = format!("{}{}", stdout, stderr);
1353
+
1354
+ let msg = if output.status.success() {
1355
+ "ok ✓"
1356
+ } else {
1357
+ &combined
1358
+ };
1359
+
1360
+ timer.track(
1361
+ &format!("git worktree {}", args.join(" ")),
1362
+ &format!("rtk git worktree {}", args.join(" ")),
1363
+ &combined,
1364
+ msg,
1365
+ );
1366
+
1367
+ if output.status.success() {
1368
+ println!("ok ✓");
1369
+ } else {
1370
+ eprintln!("FAILED: git worktree {}", args.join(" "));
1371
+ if !stderr.trim().is_empty() {
1372
+ eprintln!("{}", stderr);
1373
+ }
1374
+ std::process::exit(output.status.code().unwrap_or(1));
1375
+ }
1376
+ return Ok(());
1377
+ }
1378
+
1379
+ // Default: list mode
1380
+ let output = git_cmd(global_args)
1381
+ .args(["worktree", "list"])
1382
+ .output()
1383
+ .context("Failed to run git worktree list")?;
1384
+
1385
+ let stdout = String::from_utf8_lossy(&output.stdout);
1386
+ let raw = stdout.to_string();
1387
+
1388
+ let filtered = filter_worktree_list(&stdout);
1389
+ println!("{}", filtered);
1390
+ timer.track("git worktree list", "rtk git worktree", &raw, &filtered);
1391
+
1392
+ Ok(())
1393
+ }
1394
+
1395
+ fn filter_worktree_list(output: &str) -> String {
1396
+ let home = dirs::home_dir()
1397
+ .map(|h| h.to_string_lossy().to_string())
1398
+ .unwrap_or_default();
1399
+
1400
+ let mut result = Vec::new();
1401
+ for line in output.lines() {
1402
+ if line.trim().is_empty() {
1403
+ continue;
1404
+ }
1405
+ // Format: "/path/to/worktree abc1234 [branch]"
1406
+ let parts: Vec<&str> = line.split_whitespace().collect();
1407
+ if parts.len() >= 3 {
1408
+ let mut path = parts[0].to_string();
1409
+ if !home.is_empty() && path.starts_with(&home) {
1410
+ path = format!("~{}", &path[home.len()..]);
1411
+ }
1412
+ let hash = parts[1];
1413
+ let branch = parts[2..].join(" ");
1414
+ result.push(format!("{} {} {}", path, hash, branch));
1415
+ } else {
1416
+ result.push(line.to_string());
1417
+ }
1418
+ }
1419
+ result.join("\n")
1420
+ }
1421
+
1422
+ /// Runs an unsupported git subcommand by passing it through directly
1423
+ pub fn run_passthrough(args: &[OsString], global_args: &[String], verbose: u8) -> Result<()> {
1424
+ let timer = tracking::TimedExecution::start();
1425
+
1426
+ if verbose > 0 {
1427
+ eprintln!("git passthrough: {:?}", args);
1428
+ }
1429
+ let status = git_cmd(global_args)
1430
+ .args(args)
1431
+ .status()
1432
+ .context("Failed to run git")?;
1433
+
1434
+ let args_str = tracking::args_display(args);
1435
+ timer.track_passthrough(
1436
+ &format!("git {}", args_str),
1437
+ &format!("rtk git {} (passthrough)", args_str),
1438
+ );
1439
+
1440
+ if !status.success() {
1441
+ std::process::exit(status.code().unwrap_or(1));
1442
+ }
1443
+ Ok(())
1444
+ }
1445
+
1446
+ #[cfg(test)]
1447
+ mod tests {
1448
+ use super::*;
1449
+
1450
+ #[test]
1451
+ fn test_git_cmd_no_global_args() {
1452
+ let cmd = git_cmd(&[]);
1453
+ let program = cmd.get_program();
1454
+ assert_eq!(program, "git");
1455
+ let args: Vec<_> = cmd.get_args().collect();
1456
+ assert!(args.is_empty());
1457
+ }
1458
+
1459
+ #[test]
1460
+ fn test_git_cmd_with_directory() {
1461
+ let global_args = vec!["-C".to_string(), "/tmp".to_string()];
1462
+ let cmd = git_cmd(&global_args);
1463
+ let args: Vec<_> = cmd.get_args().collect();
1464
+ assert_eq!(args, vec!["-C", "/tmp"]);
1465
+ }
1466
+
1467
+ #[test]
1468
+ fn test_git_cmd_with_multiple_global_args() {
1469
+ let global_args = vec![
1470
+ "-C".to_string(),
1471
+ "/tmp".to_string(),
1472
+ "-c".to_string(),
1473
+ "user.name=test".to_string(),
1474
+ "--git-dir".to_string(),
1475
+ "/foo/.git".to_string(),
1476
+ ];
1477
+ let cmd = git_cmd(&global_args);
1478
+ let args: Vec<_> = cmd.get_args().collect();
1479
+ assert_eq!(
1480
+ args,
1481
+ vec![
1482
+ "-C",
1483
+ "/tmp",
1484
+ "-c",
1485
+ "user.name=test",
1486
+ "--git-dir",
1487
+ "/foo/.git"
1488
+ ]
1489
+ );
1490
+ }
1491
+
1492
+ #[test]
1493
+ fn test_git_cmd_with_boolean_flags() {
1494
+ let global_args = vec!["--no-pager".to_string(), "--bare".to_string()];
1495
+ let cmd = git_cmd(&global_args);
1496
+ let args: Vec<_> = cmd.get_args().collect();
1497
+ assert_eq!(args, vec!["--no-pager", "--bare"]);
1498
+ }
1499
+
1500
+ #[test]
1501
+ fn test_compact_diff() {
1502
+ let diff = r#"diff --git a/foo.rs b/foo.rs
1503
+ --- a/foo.rs
1504
+ +++ b/foo.rs
1505
+ @@ -1,3 +1,4 @@
1506
+ fn main() {
1507
+ + println!("hello");
1508
+ }
1509
+ "#;
1510
+ let result = compact_diff(diff, 100);
1511
+ assert!(result.contains("foo.rs"));
1512
+ assert!(result.contains("+"));
1513
+ }
1514
+
1515
+ #[test]
1516
+ fn test_compact_diff_increased_hunk_limit() {
1517
+ // Build a hunk with 25 changed lines — should NOT be truncated with limit 30
1518
+ let mut diff =
1519
+ "diff --git a/big.rs b/big.rs\n--- a/big.rs\n+++ b/big.rs\n@@ -1,25 +1,25 @@\n"
1520
+ .to_string();
1521
+ for i in 1..=25 {
1522
+ diff.push_str(&format!("+line{}\n", i));
1523
+ }
1524
+ let result = compact_diff(&diff, 500);
1525
+ assert!(
1526
+ !result.contains("... (truncated)"),
1527
+ "25 lines should not be truncated with max_hunk_lines=30"
1528
+ );
1529
+ assert!(result.contains("+line25"));
1530
+ }
1531
+
1532
+ #[test]
1533
+ fn test_compact_diff_increased_total_limit() {
1534
+ // Build a diff with 150 output result lines across multiple files — should NOT be cut at 100
1535
+ let mut diff = String::new();
1536
+ for f in 1..=5 {
1537
+ diff.push_str(&format!("diff --git a/file{f}.rs b/file{f}.rs\n--- a/file{f}.rs\n+++ b/file{f}.rs\n@@ -1,20 +1,20 @@\n"));
1538
+ for i in 1..=20 {
1539
+ diff.push_str(&format!("+line{f}_{i}\n"));
1540
+ }
1541
+ }
1542
+ let result = compact_diff(&diff, 500);
1543
+ assert!(
1544
+ !result.contains("more changes truncated"),
1545
+ "5 files × 20 lines should not exceed max_lines=500"
1546
+ );
1547
+ }
1548
+
1549
+ #[test]
1550
+ fn test_is_blob_show_arg() {
1551
+ assert!(is_blob_show_arg("develop:modules/pairs_backtest.py"));
1552
+ assert!(is_blob_show_arg("HEAD:src/main.rs"));
1553
+ assert!(!is_blob_show_arg("--pretty=format:%h"));
1554
+ assert!(!is_blob_show_arg("--format=short"));
1555
+ assert!(!is_blob_show_arg("HEAD"));
1556
+ }
1557
+
1558
+ #[test]
1559
+ fn test_filter_branch_output() {
1560
+ let output = "* main\n feature/auth\n fix/bug-123\n remotes/origin/HEAD -> origin/main\n remotes/origin/main\n remotes/origin/feature/auth\n remotes/origin/release/v2\n";
1561
+ let result = filter_branch_output(output);
1562
+ assert!(result.contains("* main"));
1563
+ assert!(result.contains("feature/auth"));
1564
+ assert!(result.contains("fix/bug-123"));
1565
+ // remote-only should show release/v2 but not main or feature/auth (already local)
1566
+ assert!(result.contains("remote-only"));
1567
+ assert!(result.contains("release/v2"));
1568
+ }
1569
+
1570
+ #[test]
1571
+ fn test_filter_branch_no_remotes() {
1572
+ let output = "* main\n develop\n";
1573
+ let result = filter_branch_output(output);
1574
+ assert!(result.contains("* main"));
1575
+ assert!(result.contains("develop"));
1576
+ assert!(!result.contains("remote-only"));
1577
+ }
1578
+
1579
+ #[test]
1580
+ fn test_filter_stash_list() {
1581
+ let output =
1582
+ "stash@{0}: WIP on main: abc1234 fix login\nstash@{1}: On feature: def5678 wip\n";
1583
+ let result = filter_stash_list(output);
1584
+ assert!(result.contains("stash@{0}: abc1234 fix login"));
1585
+ assert!(result.contains("stash@{1}: def5678 wip"));
1586
+ }
1587
+
1588
+ #[test]
1589
+ fn test_filter_worktree_list() {
1590
+ let output =
1591
+ "/home/user/project abc1234 [main]\n/home/user/worktrees/feat def5678 [feature]\n";
1592
+ let result = filter_worktree_list(output);
1593
+ assert!(result.contains("abc1234"));
1594
+ assert!(result.contains("[main]"));
1595
+ assert!(result.contains("[feature]"));
1596
+ }
1597
+
1598
+ #[test]
1599
+ fn test_format_status_output_clean() {
1600
+ let porcelain = "";
1601
+ let result = format_status_output(porcelain);
1602
+ assert_eq!(result, "Clean working tree");
1603
+ }
1604
+
1605
+ #[test]
1606
+ fn test_format_status_output_modified_files() {
1607
+ let porcelain = "## main...origin/main\n M src/main.rs\n M src/lib.rs\n";
1608
+ let result = format_status_output(porcelain);
1609
+ assert!(result.contains("📌 main...origin/main"));
1610
+ assert!(result.contains("📝 Modified: 2 files"));
1611
+ assert!(result.contains("src/main.rs"));
1612
+ assert!(result.contains("src/lib.rs"));
1613
+ assert!(!result.contains("Staged"));
1614
+ assert!(!result.contains("Untracked"));
1615
+ }
1616
+
1617
+ #[test]
1618
+ fn test_format_status_output_untracked_files() {
1619
+ let porcelain = "## feature/new\n?? temp.txt\n?? debug.log\n?? test.sh\n";
1620
+ let result = format_status_output(porcelain);
1621
+ assert!(result.contains("📌 feature/new"));
1622
+ assert!(result.contains("❓ Untracked: 3 files"));
1623
+ assert!(result.contains("temp.txt"));
1624
+ assert!(result.contains("debug.log"));
1625
+ assert!(result.contains("test.sh"));
1626
+ assert!(!result.contains("Modified"));
1627
+ }
1628
+
1629
+ #[test]
1630
+ fn test_format_status_output_mixed_changes() {
1631
+ let porcelain = r#"## main
1632
+ M staged.rs
1633
+ M modified.rs
1634
+ A added.rs
1635
+ ?? untracked.txt
1636
+ "#;
1637
+ let result = format_status_output(porcelain);
1638
+ assert!(result.contains("📌 main"));
1639
+ assert!(result.contains("✅ Staged: 2 files"));
1640
+ assert!(result.contains("staged.rs"));
1641
+ assert!(result.contains("added.rs"));
1642
+ assert!(result.contains("📝 Modified: 1 files"));
1643
+ assert!(result.contains("modified.rs"));
1644
+ assert!(result.contains("❓ Untracked: 1 files"));
1645
+ assert!(result.contains("untracked.txt"));
1646
+ }
1647
+
1648
+ #[test]
1649
+ fn test_format_status_output_truncation() {
1650
+ // Test that >5 staged files show "... +N more"
1651
+ let porcelain = r#"## main
1652
+ M file1.rs
1653
+ M file2.rs
1654
+ M file3.rs
1655
+ M file4.rs
1656
+ M file5.rs
1657
+ M file6.rs
1658
+ M file7.rs
1659
+ "#;
1660
+ let result = format_status_output(porcelain);
1661
+ assert!(result.contains("✅ Staged: 7 files"));
1662
+ assert!(result.contains("file1.rs"));
1663
+ assert!(result.contains("file5.rs"));
1664
+ assert!(result.contains("... +2 more"));
1665
+ assert!(!result.contains("file6.rs"));
1666
+ assert!(!result.contains("file7.rs"));
1667
+ }
1668
+
1669
+ #[test]
1670
+ fn test_run_passthrough_accepts_args() {
1671
+ // Test that run_passthrough compiles and has correct signature
1672
+ let _args: Vec<OsString> = vec![OsString::from("tag"), OsString::from("--list")];
1673
+ // Compile-time verification that the function exists with correct signature
1674
+ }
1675
+
1676
+ #[test]
1677
+ fn test_filter_log_output() {
1678
+ let output = "abc1234 This is a commit message (2 days ago) <author>\ndef5678 Another commit (1 week ago) <other>\n";
1679
+ let result = filter_log_output(output, 10, false);
1680
+ assert!(result.contains("abc1234"));
1681
+ assert!(result.contains("def5678"));
1682
+ assert_eq!(result.lines().count(), 2);
1683
+ }
1684
+
1685
+ #[test]
1686
+ fn test_filter_log_output_truncate_long() {
1687
+ let long_line = "abc1234 ".to_string() + &"x".repeat(100) + " (2 days ago) <author>";
1688
+ let result = filter_log_output(&long_line, 10, false);
1689
+ assert!(result.chars().count() < long_line.chars().count());
1690
+ assert!(result.contains("..."));
1691
+ assert!(result.chars().count() <= 80);
1692
+ }
1693
+
1694
+ #[test]
1695
+ fn test_filter_log_output_cap_lines() {
1696
+ let output = (0..20)
1697
+ .map(|i| format!("hash{} message {} (1 day ago) <author>", i, i))
1698
+ .collect::<Vec<_>>()
1699
+ .join("\n");
1700
+ let result = filter_log_output(&output, 5, false);
1701
+ assert_eq!(result.lines().count(), 5);
1702
+ }
1703
+
1704
+ #[test]
1705
+ fn test_filter_log_output_user_limit_no_cap() {
1706
+ // When user explicitly passes -N, all N lines should be returned (no re-truncation)
1707
+ let output = (0..20)
1708
+ .map(|i| format!("hash{} message {} (1 day ago) <author>", i, i))
1709
+ .collect::<Vec<_>>()
1710
+ .join("\n");
1711
+ let result = filter_log_output(&output, 20, true);
1712
+ assert_eq!(
1713
+ result.lines().count(),
1714
+ 20,
1715
+ "User's -20 should return all 20 lines"
1716
+ );
1717
+ }
1718
+
1719
+ #[test]
1720
+ fn test_filter_log_output_user_limit_wider_truncation() {
1721
+ // When user explicitly passes -N, lines up to 120 chars should NOT be truncated
1722
+ let line_90_chars = format!("abc1234 {} (2 days ago) <author>", "x".repeat(60));
1723
+ assert!(line_90_chars.chars().count() > 80);
1724
+ assert!(line_90_chars.chars().count() < 120);
1725
+
1726
+ let result_default = filter_log_output(&line_90_chars, 10, false);
1727
+ let result_user = filter_log_output(&line_90_chars, 10, true);
1728
+
1729
+ // Default truncates at 80 chars
1730
+ assert!(
1731
+ result_default.contains("..."),
1732
+ "Default should truncate at 80 chars"
1733
+ );
1734
+ // User-set limit uses wider threshold (120 chars)
1735
+ assert!(
1736
+ !result_user.contains("..."),
1737
+ "User limit should not truncate 90-char line"
1738
+ );
1739
+ }
1740
+
1741
+ #[test]
1742
+ fn test_parse_user_limit_combined() {
1743
+ let args: Vec<String> = vec!["-20".into()];
1744
+ assert_eq!(parse_user_limit(&args), Some(20));
1745
+ }
1746
+
1747
+ #[test]
1748
+ fn test_parse_user_limit_n_space() {
1749
+ let args: Vec<String> = vec!["-n".into(), "15".into()];
1750
+ assert_eq!(parse_user_limit(&args), Some(15));
1751
+ }
1752
+
1753
+ #[test]
1754
+ fn test_parse_user_limit_max_count_eq() {
1755
+ let args: Vec<String> = vec!["--max-count=30".into()];
1756
+ assert_eq!(parse_user_limit(&args), Some(30));
1757
+ }
1758
+
1759
+ #[test]
1760
+ fn test_parse_user_limit_max_count_space() {
1761
+ let args: Vec<String> = vec!["--max-count".into(), "25".into()];
1762
+ assert_eq!(parse_user_limit(&args), Some(25));
1763
+ }
1764
+
1765
+ #[test]
1766
+ fn test_parse_user_limit_none() {
1767
+ let args: Vec<String> = vec!["--oneline".into()];
1768
+ assert_eq!(parse_user_limit(&args), None);
1769
+ }
1770
+
1771
+ #[test]
1772
+ fn test_filter_log_output_token_savings() {
1773
+ fn count_tokens(text: &str) -> usize {
1774
+ text.split_whitespace().count()
1775
+ }
1776
+ // Simulate verbose git log output (default format with full metadata)
1777
+ let input = (0..20)
1778
+ .map(|i| {
1779
+ format!(
1780
+ "commit abc123{:02x}\nAuthor: User Name <user@example.com>\nDate: Mon Mar 10 10:00:00 2026 +0000\n\n fix: commit message number {}\n\n Extended body with details about the change.\n",
1781
+ i, i
1782
+ )
1783
+ })
1784
+ .collect::<Vec<_>>()
1785
+ .join("\n");
1786
+ let output = filter_log_output(&input, 10, false);
1787
+ let savings = 100.0 - (count_tokens(&output) as f64 / count_tokens(&input) as f64 * 100.0);
1788
+ assert!(
1789
+ savings >= 60.0,
1790
+ "Expected ≥60% token savings, got {:.1}%",
1791
+ savings
1792
+ );
1793
+ }
1794
+
1795
+ #[test]
1796
+ fn test_filter_status_with_args() {
1797
+ let output = r#"On branch main
1798
+ Your branch is up to date with 'origin/main'.
1799
+
1800
+ Changes not staged for commit:
1801
+ (use "git add <file>..." to update what will be committed)
1802
+ (use "git restore <file>..." to discard changes in working directory)
1803
+ modified: src/main.rs
1804
+
1805
+ no changes added to commit (use "git add" and/or "git commit -a")
1806
+ "#;
1807
+ let result = filter_status_with_args(output);
1808
+ eprintln!("Result:\n{}", result);
1809
+ assert!(result.contains("On branch main"));
1810
+ assert!(result.contains("modified: src/main.rs"));
1811
+ assert!(
1812
+ !result.contains("(use \"git"),
1813
+ "Result should not contain git hints"
1814
+ );
1815
+ }
1816
+
1817
+ #[test]
1818
+ fn test_filter_status_with_args_clean() {
1819
+ let output = "nothing to commit, working tree clean\n";
1820
+ let result = filter_status_with_args(output);
1821
+ assert!(result.contains("nothing to commit"));
1822
+ }
1823
+
1824
+ #[test]
1825
+ fn test_filter_log_output_multibyte() {
1826
+ // Thai characters: each is 3 bytes. A line with >80 bytes but few chars
1827
+ let thai_msg = format!("abc1234 {} (2 days ago) <author>", "ก".repeat(30));
1828
+ let result = filter_log_output(&thai_msg, 10, false);
1829
+ // Should not panic
1830
+ assert!(result.contains("abc1234"));
1831
+ // The line has 30 Thai chars + other text, so > 80 chars total
1832
+ // truncate_line now counts chars, not bytes
1833
+ // 30 Thai + ~33 other = 63 chars < 80 threshold, so no truncation
1834
+ assert!(result.contains("abc1234"));
1835
+ }
1836
+
1837
+ #[test]
1838
+ fn test_filter_log_output_emoji() {
1839
+ let emoji_msg = "abc1234 🎉🎊🎈🎁🎂🎄🎃🎆🎇✨🎉🎊🎈🎁🎂🎄🎃🎆🎇✨ (1 day ago) <user>";
1840
+ let result = filter_log_output(emoji_msg, 10, false);
1841
+ // Should not panic
1842
+ // 20 emoji + ~30 other chars = ~50 chars < 80, no truncation needed
1843
+ assert!(result.contains("abc1234"));
1844
+ }
1845
+
1846
+ #[test]
1847
+ fn test_format_status_output_thai_filename() {
1848
+ let porcelain = "## main\n M สวัสดี.txt\n?? ทดสอบ.rs\n";
1849
+ let result = format_status_output(porcelain);
1850
+ // Should not panic
1851
+ assert!(result.contains("📌 main"));
1852
+ assert!(result.contains("สวัสดี.txt"));
1853
+ assert!(result.contains("ทดสอบ.rs"));
1854
+ }
1855
+
1856
+ #[test]
1857
+ fn test_format_status_output_emoji_filename() {
1858
+ let porcelain = "## main\nA 🎉-party.txt\n M 日本語ファイル.rs\n";
1859
+ let result = format_status_output(porcelain);
1860
+ assert!(result.contains("📌 main"));
1861
+ }
1862
+
1863
+ /// Regression test: `git branch <name>` must create, not list.
1864
+ /// Before fix, positional args fell into list mode which added `-a`,
1865
+ /// turning creation into a pattern-filtered listing (silent no-op).
1866
+ #[test]
1867
+ #[ignore] // Integration test: requires git repo
1868
+ fn test_branch_creation_not_swallowed() {
1869
+ let branch = "test-rtk-create-branch-regression";
1870
+ // Create branch via run_branch
1871
+ run_branch(&[branch.to_string()], 0, &[]).expect("run_branch should succeed");
1872
+ // Verify it exists
1873
+ let output = Command::new("git")
1874
+ .args(["branch", "--list", branch])
1875
+ .output()
1876
+ .expect("git branch --list should work");
1877
+ let stdout = String::from_utf8_lossy(&output.stdout);
1878
+ assert!(
1879
+ stdout.contains(branch),
1880
+ "Branch '{}' was not created. run_branch silently swallowed the creation.",
1881
+ branch
1882
+ );
1883
+ // Cleanup
1884
+ let _ = Command::new("git").args(["branch", "-d", branch]).output();
1885
+ }
1886
+
1887
+ /// Regression test: `git branch <name> <commit>` must create from commit.
1888
+ #[test]
1889
+ #[ignore] // Integration test: requires git repo
1890
+ fn test_branch_creation_from_commit() {
1891
+ let branch = "test-rtk-create-from-commit";
1892
+ run_branch(&[branch.to_string(), "HEAD".to_string()], 0, &[])
1893
+ .expect("run_branch with start-point should succeed");
1894
+ let output = Command::new("git")
1895
+ .args(["branch", "--list", branch])
1896
+ .output()
1897
+ .expect("git branch --list should work");
1898
+ let stdout = String::from_utf8_lossy(&output.stdout);
1899
+ assert!(
1900
+ stdout.contains(branch),
1901
+ "Branch '{}' was not created from commit.",
1902
+ branch
1903
+ );
1904
+ let _ = Command::new("git").args(["branch", "-d", branch]).output();
1905
+ }
1906
+
1907
+ #[test]
1908
+ fn test_commit_single_message() {
1909
+ let args = vec!["-m".to_string(), "fix: typo".to_string()];
1910
+ let cmd = build_commit_command(&args, &[]);
1911
+ let cmd_args: Vec<_> = cmd
1912
+ .get_args()
1913
+ .map(|a| a.to_string_lossy().to_string())
1914
+ .collect();
1915
+ assert_eq!(cmd_args, vec!["commit", "-m", "fix: typo"]);
1916
+ }
1917
+
1918
+ #[test]
1919
+ fn test_commit_multiple_messages() {
1920
+ let args = vec![
1921
+ "-m".to_string(),
1922
+ "feat: add multi-paragraph support".to_string(),
1923
+ "-m".to_string(),
1924
+ "This allows git commit -m \"title\" -m \"body\".".to_string(),
1925
+ ];
1926
+ let cmd = build_commit_command(&args, &[]);
1927
+ let cmd_args: Vec<_> = cmd
1928
+ .get_args()
1929
+ .map(|a| a.to_string_lossy().to_string())
1930
+ .collect();
1931
+ assert_eq!(
1932
+ cmd_args,
1933
+ vec![
1934
+ "commit",
1935
+ "-m",
1936
+ "feat: add multi-paragraph support",
1937
+ "-m",
1938
+ "This allows git commit -m \"title\" -m \"body\"."
1939
+ ]
1940
+ );
1941
+ }
1942
+
1943
+ // #327: git commit -am "msg" must pass -am through to git
1944
+ #[test]
1945
+ fn test_commit_am_flag() {
1946
+ let args = vec!["-am".to_string(), "quick fix".to_string()];
1947
+ let cmd = build_commit_command(&args, &[]);
1948
+ let cmd_args: Vec<_> = cmd
1949
+ .get_args()
1950
+ .map(|a| a.to_string_lossy().to_string())
1951
+ .collect();
1952
+ assert_eq!(cmd_args, vec!["commit", "-am", "quick fix"]);
1953
+ }
1954
+
1955
+ #[test]
1956
+ fn test_commit_amend() {
1957
+ let args = vec![
1958
+ "--amend".to_string(),
1959
+ "-m".to_string(),
1960
+ "new msg".to_string(),
1961
+ ];
1962
+ let cmd = build_commit_command(&args, &[]);
1963
+ let cmd_args: Vec<_> = cmd
1964
+ .get_args()
1965
+ .map(|a| a.to_string_lossy().to_string())
1966
+ .collect();
1967
+ assert_eq!(cmd_args, vec!["commit", "--amend", "-m", "new msg"]);
1968
+ }
1969
+
1970
+ #[test]
1971
+ #[ignore] // Requires `cargo build` first — run with `cargo test --ignored`
1972
+ fn test_git_status_not_a_repo_exits_nonzero() {
1973
+ // Run rtk git status in a directory that is not a git repo
1974
+ let tmp = std::env::temp_dir().join("rtk_test_not_a_repo");
1975
+ let _ = std::fs::create_dir_all(&tmp);
1976
+
1977
+ // Build the path to the test binary
1978
+ let bin_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1979
+ .join("target")
1980
+ .join("debug")
1981
+ .join("rtk");
1982
+ assert!(
1983
+ bin_path.exists(),
1984
+ "Debug binary not found at {:?} — run `cargo build` first",
1985
+ bin_path
1986
+ );
1987
+ let output = std::process::Command::new(&bin_path)
1988
+ .args(["git", "status"])
1989
+ .current_dir(&tmp)
1990
+ .output()
1991
+ .expect("Failed to run rtk");
1992
+
1993
+ // Should exit with non-zero (128 from git)
1994
+ assert!(
1995
+ !output.status.success(),
1996
+ "Expected non-zero exit code for git status outside a repo, got {:?}",
1997
+ output.status.code()
1998
+ );
1999
+
2000
+ // Message should be on stderr, not stdout
2001
+ let stderr = String::from_utf8_lossy(&output.stderr);
2002
+ let stdout = String::from_utf8_lossy(&output.stdout);
2003
+ assert!(
2004
+ stderr.to_lowercase().contains("not a git repository"),
2005
+ "Expected 'not a git repository' on stderr, got stderr={:?}, stdout={:?}",
2006
+ stderr,
2007
+ stdout
2008
+ );
2009
+
2010
+ let _ = std::fs::remove_dir_all(&tmp);
2011
+ }
2012
+ }