1use std::fmt;
2
3#[derive(Debug, Clone)]
4pub struct BlockedCommand {
5 pub command: &'static str,
6 pub reason: &'static str,
7 pub suggestion: &'static str,
8}
9
10impl fmt::Display for BlockedCommand {
11 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
12 write!(
13 f,
14 "BLOCKED: `{}` — {}\n\nThis is a jj-colocated repo. Use jj instead:\n\n {}",
15 self.command, self.reason, self.suggestion
16 )
17 }
18}
19
20static BLOCKED_COMMANDS: &[(&str, BlockedCommand)] = &[
21 ("commit", BlockedCommand {
23 command: "git commit",
24 reason: "git commit bypasses jj's change tracking.",
25 suggestion: "jj describe -m '...' (set message on current change)\n jj new (start a new change after the current one)",
26 }),
27 ("rebase", BlockedCommand {
28 command: "git rebase",
29 reason: "git rebase conflicts with jj's history management.",
30 suggestion: "jj rebase -s <source> -o <destination>\n jj rebase -b @ -o main@origin (rebase current work onto updated remote)",
31 }),
32 ("merge", BlockedCommand {
33 command: "git merge",
34 reason: "git merge conflicts with jj's history management.",
35 suggestion: "jj new <parent-a> <parent-b> (create a merge change)",
36 }),
37 ("stash", BlockedCommand {
38 command: "git stash",
39 reason: "jj snapshots the working copy automatically — stash is unnecessary.",
40 suggestion: "jj new (start fresh change; previous work is already snapshotted)",
41 }),
42 ("revert", BlockedCommand {
43 command: "git revert",
44 reason: "git revert bypasses jj's change tracking.",
45 suggestion: "jj backout -r <change-id>",
46 }),
47 ("cherry-pick", BlockedCommand {
48 command: "git cherry-pick",
49 reason: "git cherry-pick bypasses jj's change tracking.",
50 suggestion: "jj duplicate <change-id> then jj rebase -s <new> -o <destination>",
51 }),
52 ("reset", BlockedCommand {
53 command: "git reset",
54 reason: "git reset modifies state that jj manages.",
55 suggestion: "jj restore <path> (restore files from parent change)\n jj abandon (discard a change entirely)",
56 }),
57 ("checkout", BlockedCommand {
59 command: "git checkout",
60 reason: "git checkout conflicts with jj's working-copy management.",
61 suggestion: "jj edit <change-id> (switch to an existing change)\n jj new <parent> (start new work after a change)\n jj restore --from <change-id> <path> (restore specific files)",
62 }),
63 ("switch", BlockedCommand {
64 command: "git switch",
65 reason: "git switch conflicts with jj's working-copy management.",
66 suggestion: "jj edit <change-id> (switch to an existing change)\n jj new <parent> (start new work after a change)",
67 }),
68 ("add", BlockedCommand {
70 command: "git add",
71 reason: "jj has no staging area — it snapshots the working copy automatically.",
72 suggestion: "No action needed for tracked files — jj sees changes automatically.\n jj file track <path> (start tracking a new untracked file)",
73 }),
74 ("rm", BlockedCommand {
75 command: "git rm",
76 reason: "jj has no staging area — file tracking works differently.",
77 suggestion: "jj file untrack <path> (stop tracking a file)\n Or just delete the file — jj will notice.",
78 }),
79 ("restore", BlockedCommand {
80 command: "git restore",
81 reason: "git restore conflicts with jj's working-copy management.",
82 suggestion: "jj restore <path> (restore files from parent)\n jj restore --from <change-id> <path> (restore from a specific change)",
83 }),
84 ("clean", BlockedCommand {
85 command: "git clean",
86 reason: "git clean can delete files that jj is tracking.",
87 suggestion: "jj restore (restore the working copy to match the parent change)",
88 }),
89 ("branch", BlockedCommand {
91 command: "git branch",
92 reason: "jj uses bookmarks instead of git branches.",
93 suggestion: "jj bookmark list (list bookmarks)\n jj bookmark create <name> (create a bookmark at @)\n jj bookmark delete <name> (delete a bookmark)\n jj bookmark move --to <change-id> <name> (move a bookmark)",
94 }),
95 ("tag", BlockedCommand {
96 command: "git tag",
97 reason: "jj does not manage tags directly.",
98 suggestion: "jj tag list (list tags)\n jj git push --tag <name> (push a tag to remote)",
99 }),
100 ("push", BlockedCommand {
102 command: "git push",
103 reason: "jj manages push safety automatically — use jj git push.",
104 suggestion: "jj git push --bookmark <name> (push a specific bookmark)\n jj git push --change <change-id> (push and auto-create bookmark)\n jj git push --all (push all bookmarks)",
105 }),
106 ("fetch", BlockedCommand {
107 command: "git fetch",
108 reason: "Use jj git fetch to keep jj's view of remotes consistent.",
109 suggestion: "jj git fetch (fetch from all remotes)\n jj git fetch --remote <name> (fetch from a specific remote)",
110 }),
111 ("pull", BlockedCommand {
112 command: "git pull",
113 reason: "jj has no pull — fetch and rebase are separate steps.",
114 suggestion: "jj git fetch (fetch latest from remote)\n jj rebase -b @ -o main@origin (rebase onto updated remote, if needed)",
115 }),
116 ("clone", BlockedCommand {
118 command: "git clone",
119 reason: "Use jj git clone to get a jj-native repo from the start.",
120 suggestion: "jj git clone --colocate <url> (clone with jj+git colocated)",
121 }),
122 ("init", BlockedCommand {
123 command: "git init",
124 reason: "Use jj git init to set up jj from the start.",
125 suggestion: "jj git init --colocate (initialize jj+git colocated repo)",
126 }),
127 ("remote", BlockedCommand {
128 command: "git remote",
129 reason: "Use jj git remote to keep jj's view consistent.",
130 suggestion: "jj git remote list (list remotes)\n jj git remote add <name> <url> (add a remote)\n jj git remote remove <name> (remove a remote)\n jj git remote rename <old> <new> (rename a remote)",
131 }),
132 ("status", BlockedCommand {
134 command: "git status",
135 reason: "jj status understands jj's change model and shows richer information.",
136 suggestion: "jj status (show working-copy changes and current change info)",
137 }),
138 ("log", BlockedCommand {
139 command: "git log",
140 reason: "jj log shows the change graph with change-ids, which are more useful than commit SHAs.",
141 suggestion: "jj log (show change graph)\n jj log -r <revset> (filter, e.g. 'jj log -r main..@')",
142 }),
143 ("diff", BlockedCommand {
144 command: "git diff",
145 reason: "jj diff understands jj's change model and requires no staging.",
146 suggestion: "jj diff (diff of current change vs parent)\n jj diff -r <change-id> (diff of a specific change)\n jj diff --from <a> --to <b> (diff between two revisions)\n jj diff --git (unified diff format, like git diff)\n jj diff --stat (summary of changes)",
147 }),
148 ("show", BlockedCommand {
149 command: "git show",
150 reason: "jj show uses change-ids and understands jj's change model.",
151 suggestion: "jj show <change-id> (show a specific change with message and diff)",
152 }),
153 ("blame", BlockedCommand {
154 command: "git blame",
155 reason: "jj has its own annotation command.",
156 suggestion: "jj file annotate <path> (show per-line change attribution)",
157 }),
158];
159
160pub fn check_git_command(words: &[String]) -> Option<&'static BlockedCommand> {
161 let git_idx = words.iter().position(|w| w == "git")?;
162 let subcommand = find_git_subcommand(words, git_idx)?;
163
164 for (name, blocked) in BLOCKED_COMMANDS {
165 if subcommand == *name {
166 return Some(blocked);
167 }
168 }
169
170 if subcommand == "worktree" {
172 let rest = &words[git_idx..];
173 let wt_sub = rest.iter().skip_while(|w| *w != "worktree").nth(1);
174 if let Some(wt_cmd) = wt_sub {
175 if wt_cmd != "list" && wt_cmd != "repair" {
176 static WORKTREE_BLOCKED: BlockedCommand = BlockedCommand {
177 command: "git worktree",
178 reason: "git worktrees are invisible to jj — use jj workspaces instead.",
179 suggestion: "jj workspace add <path> --name <name> (create)\n jj workspace forget <name> (remove)",
180 };
181 return Some(&WORKTREE_BLOCKED);
182 }
183 }
184 }
185
186 None
187}
188
189const GIT_GLOBAL_ARG_FLAGS: &[&str] = &["-C", "-c", "--git-dir", "--work-tree", "--namespace"];
190const GIT_GLOBAL_SOLO_FLAGS: &[&str] = &["--bare", "--no-pager", "--no-replace-objects"];
191
192fn find_git_subcommand(words: &[String], git_idx: usize) -> Option<String> {
193 let mut i = git_idx + 1;
194 while i < words.len() {
195 let word = &words[i];
196 if GIT_GLOBAL_ARG_FLAGS.iter().any(|f| word == f) {
197 i += 2;
198 } else if GIT_GLOBAL_SOLO_FLAGS.iter().any(|f| word == f) || word.starts_with('-') {
199 i += 1;
200 } else {
201 return Some(word.clone());
202 }
203 }
204 None
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 fn words(s: &str) -> Vec<String> {
212 shell_words::split(s).unwrap()
213 }
214
215 #[test]
218 fn blocks_git_commit() {
219 let w = words("git commit -m 'test'");
220 assert!(check_git_command(&w).is_some());
221 }
222
223 #[test]
224 fn blocks_git_rebase() {
225 let w = words("git rebase main");
226 assert!(check_git_command(&w).is_some());
227 }
228
229 #[test]
230 fn blocks_git_merge() {
231 let w = words("git merge feature-branch");
232 assert!(check_git_command(&w).is_some());
233 }
234
235 #[test]
236 fn blocks_git_stash() {
237 let w = words("git stash pop");
238 assert!(check_git_command(&w).is_some());
239 }
240
241 #[test]
242 fn blocks_git_revert() {
243 let w = words("git revert HEAD");
244 assert!(check_git_command(&w).is_some());
245 }
246
247 #[test]
248 fn blocks_git_cherry_pick() {
249 let w = words("git cherry-pick abc123");
250 assert!(check_git_command(&w).is_some());
251 }
252
253 #[test]
254 fn blocks_git_reset_hard() {
255 let w = words("git reset --hard HEAD~1");
256 assert!(check_git_command(&w).is_some());
257 }
258
259 #[test]
260 fn blocks_git_reset_without_flags() {
261 let w = words("git reset HEAD file.rs");
262 assert!(check_git_command(&w).is_some());
263 }
264
265 #[test]
268 fn blocks_git_checkout() {
269 let w = words("git checkout main");
270 assert!(check_git_command(&w).is_some());
271 }
272
273 #[test]
274 fn blocks_git_checkout_dash_b() {
275 let w = words("git checkout -b new-branch");
276 assert!(check_git_command(&w).is_some());
277 }
278
279 #[test]
280 fn blocks_bare_git_checkout() {
281 let w = words("git checkout");
282 assert!(check_git_command(&w).is_some());
283 }
284
285 #[test]
286 fn blocks_git_switch() {
287 let w = words("git switch feature-branch");
288 assert!(check_git_command(&w).is_some());
289 }
290
291 #[test]
294 fn blocks_git_add() {
295 let w = words("git add .");
296 assert!(check_git_command(&w).is_some());
297 }
298
299 #[test]
300 fn blocks_git_rm() {
301 let w = words("git rm --cached file.rs");
302 assert!(check_git_command(&w).is_some());
303 }
304
305 #[test]
306 fn blocks_git_restore() {
307 let w = words("git restore --source HEAD~1 file.rs");
308 assert!(check_git_command(&w).is_some());
309 }
310
311 #[test]
312 fn blocks_git_clean() {
313 let w = words("git clean -fd");
314 assert!(check_git_command(&w).is_some());
315 }
316
317 #[test]
320 fn blocks_git_branch_list() {
321 let w = words("git branch");
322 assert!(check_git_command(&w).is_some());
323 }
324
325 #[test]
326 fn blocks_git_branch_create() {
327 let w = words("git branch new-feature");
328 assert!(check_git_command(&w).is_some());
329 }
330
331 #[test]
332 fn blocks_git_branch_delete() {
333 let w = words("git branch -D feature-x");
334 assert!(check_git_command(&w).is_some());
335 }
336
337 #[test]
338 fn blocks_git_tag() {
339 let w = words("git tag v1.0.0");
340 assert!(check_git_command(&w).is_some());
341 }
342
343 #[test]
346 fn blocks_git_push() {
347 let w = words("git push origin main");
348 assert!(check_git_command(&w).is_some());
349 }
350
351 #[test]
352 fn blocks_git_push_force() {
353 let w = words("git push --force origin main");
354 assert!(check_git_command(&w).is_some());
355 }
356
357 #[test]
358 fn blocks_git_fetch() {
359 let w = words("git fetch origin");
360 assert!(check_git_command(&w).is_some());
361 }
362
363 #[test]
364 fn blocks_git_pull() {
365 let w = words("git pull --rebase origin main");
366 assert!(check_git_command(&w).is_some());
367 }
368
369 #[test]
370 fn blocks_git_clone() {
371 let w = words("git clone https://github.com/example/repo.git");
372 assert!(check_git_command(&w).is_some());
373 }
374
375 #[test]
376 fn blocks_git_init() {
377 let w = words("git init");
378 assert!(check_git_command(&w).is_some());
379 }
380
381 #[test]
382 fn blocks_git_remote() {
383 let w = words("git remote add origin https://github.com/example/repo.git");
384 assert!(check_git_command(&w).is_some());
385 }
386
387 #[test]
390 fn blocks_git_status() {
391 let w = words("git status");
392 assert!(check_git_command(&w).is_some());
393 }
394
395 #[test]
396 fn blocks_git_log() {
397 let w = words("git log --oneline -10");
398 assert!(check_git_command(&w).is_some());
399 }
400
401 #[test]
402 fn blocks_git_diff() {
403 let w = words("git diff --stat");
404 assert!(check_git_command(&w).is_some());
405 }
406
407 #[test]
408 fn blocks_git_show() {
409 let w = words("git show HEAD");
410 assert!(check_git_command(&w).is_some());
411 }
412
413 #[test]
414 fn blocks_git_blame() {
415 let w = words("git blame src/main.rs");
416 assert!(check_git_command(&w).is_some());
417 }
418
419 #[test]
422 fn blocks_git_worktree_add() {
423 let w = words("git worktree add ../other-dir");
424 assert!(check_git_command(&w).is_some());
425 }
426
427 #[test]
428 fn allows_git_worktree_list() {
429 let w = words("git worktree list");
430 assert!(check_git_command(&w).is_none());
431 }
432
433 #[test]
434 fn allows_git_worktree_repair() {
435 let w = words("git worktree repair");
436 assert!(check_git_command(&w).is_none());
437 }
438
439 #[test]
442 fn allows_gh_commands() {
443 let w = words("gh pr create --title test");
444 assert!(check_git_command(&w).is_none());
445 }
446
447 #[test]
448 fn allows_git_config() {
449 let w = words("git config user.name");
450 assert!(check_git_command(&w).is_none());
451 }
452
453 #[test]
454 fn allows_git_bisect() {
455 let w = words("git bisect start");
456 assert!(check_git_command(&w).is_none());
457 }
458
459 #[test]
462 fn handles_git_with_global_flags() {
463 let w = words("git -C /tmp/repo status");
464 assert!(check_git_command(&w).is_some());
465 }
466
467 #[test]
468 fn handles_git_no_pager() {
469 let w = words("git --no-pager log");
470 assert!(check_git_command(&w).is_some());
471 }
472
473 #[test]
476 fn status_suggestion_mentions_jj_status() {
477 let w = words("git status");
478 let blocked = check_git_command(&w).unwrap();
479 assert!(blocked.suggestion.contains("jj status"), "suggestion should mention jj status");
480 }
481
482 #[test]
483 fn diff_suggestion_covers_common_forms() {
484 let w = words("git diff");
485 let blocked = check_git_command(&w).unwrap();
486 assert!(blocked.suggestion.contains("jj diff"), "should mention jj diff");
487 assert!(blocked.suggestion.contains("--from"), "should mention --from/--to form");
488 assert!(blocked.suggestion.contains("--git"), "should mention --git for unified diff format");
489 assert!(blocked.suggestion.contains("--stat"), "should mention --stat");
490 }
491
492 #[test]
493 fn push_suggestion_mentions_bookmark() {
494 let w = words("git push origin main");
495 let blocked = check_git_command(&w).unwrap();
496 assert!(blocked.suggestion.contains("jj git push"), "should mention jj git push");
497 assert!(blocked.suggestion.contains("--bookmark"), "should mention --bookmark");
498 }
499
500 #[test]
501 fn branch_suggestion_covers_list_create_delete() {
502 let w = words("git branch");
503 let blocked = check_git_command(&w).unwrap();
504 assert!(blocked.suggestion.contains("bookmark list"), "should cover list");
505 assert!(blocked.suggestion.contains("bookmark create"), "should cover create");
506 assert!(blocked.suggestion.contains("bookmark delete"), "should cover delete");
507 }
508}