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 cmd_idx = crate::find_command_position(words)?;
162 if words[cmd_idx] != "git" {
163 return None;
164 }
165 let subcommand = find_git_subcommand(words, cmd_idx)?;
166
167 for (name, blocked) in BLOCKED_COMMANDS {
168 if subcommand == *name {
169 return Some(blocked);
170 }
171 }
172
173 if subcommand == "worktree" {
175 let rest = &words[cmd_idx..];
176 let wt_sub = rest.iter().skip_while(|w| *w != "worktree").nth(1);
177 if let Some(wt_cmd) = wt_sub {
178 if wt_cmd != "list" && wt_cmd != "repair" {
179 static WORKTREE_BLOCKED: BlockedCommand = BlockedCommand {
180 command: "git worktree",
181 reason: "git worktrees are invisible to jj — use jj workspaces instead.",
182 suggestion: "jj workspace add <path> --name <name> (create)\n jj workspace forget <name> (remove)",
183 };
184 return Some(&WORKTREE_BLOCKED);
185 }
186 }
187 }
188
189 None
190}
191
192const GIT_GLOBAL_ARG_FLAGS: &[&str] = &["-C", "-c", "--git-dir", "--work-tree", "--namespace"];
193const GIT_GLOBAL_SOLO_FLAGS: &[&str] = &["--bare", "--no-pager", "--no-replace-objects"];
194
195fn find_git_subcommand(words: &[String], git_idx: usize) -> Option<String> {
196 let mut i = git_idx + 1;
197 while i < words.len() {
198 let word = &words[i];
199 if GIT_GLOBAL_ARG_FLAGS.iter().any(|f| word == f) {
200 i += 2;
201 } else if GIT_GLOBAL_SOLO_FLAGS.iter().any(|f| word == f) || word.starts_with('-') {
202 i += 1;
203 } else {
204 return Some(word.clone());
205 }
206 }
207 None
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 fn words(s: &str) -> Vec<String> {
215 shell_words::split(s).unwrap()
216 }
217
218 #[test]
221 fn blocks_git_commit() {
222 let w = words("git commit -m 'test'");
223 assert!(check_git_command(&w).is_some());
224 }
225
226 #[test]
227 fn blocks_git_rebase() {
228 let w = words("git rebase main");
229 assert!(check_git_command(&w).is_some());
230 }
231
232 #[test]
233 fn blocks_git_merge() {
234 let w = words("git merge feature-branch");
235 assert!(check_git_command(&w).is_some());
236 }
237
238 #[test]
239 fn blocks_git_stash() {
240 let w = words("git stash pop");
241 assert!(check_git_command(&w).is_some());
242 }
243
244 #[test]
245 fn blocks_git_revert() {
246 let w = words("git revert HEAD");
247 assert!(check_git_command(&w).is_some());
248 }
249
250 #[test]
251 fn blocks_git_cherry_pick() {
252 let w = words("git cherry-pick abc123");
253 assert!(check_git_command(&w).is_some());
254 }
255
256 #[test]
257 fn blocks_git_reset_hard() {
258 let w = words("git reset --hard HEAD~1");
259 assert!(check_git_command(&w).is_some());
260 }
261
262 #[test]
263 fn blocks_git_reset_without_flags() {
264 let w = words("git reset HEAD file.rs");
265 assert!(check_git_command(&w).is_some());
266 }
267
268 #[test]
271 fn blocks_git_checkout() {
272 let w = words("git checkout main");
273 assert!(check_git_command(&w).is_some());
274 }
275
276 #[test]
277 fn blocks_git_checkout_dash_b() {
278 let w = words("git checkout -b new-branch");
279 assert!(check_git_command(&w).is_some());
280 }
281
282 #[test]
283 fn blocks_bare_git_checkout() {
284 let w = words("git checkout");
285 assert!(check_git_command(&w).is_some());
286 }
287
288 #[test]
289 fn blocks_git_switch() {
290 let w = words("git switch feature-branch");
291 assert!(check_git_command(&w).is_some());
292 }
293
294 #[test]
297 fn blocks_git_add() {
298 let w = words("git add .");
299 assert!(check_git_command(&w).is_some());
300 }
301
302 #[test]
303 fn blocks_git_rm() {
304 let w = words("git rm --cached file.rs");
305 assert!(check_git_command(&w).is_some());
306 }
307
308 #[test]
309 fn blocks_git_restore() {
310 let w = words("git restore --source HEAD~1 file.rs");
311 assert!(check_git_command(&w).is_some());
312 }
313
314 #[test]
315 fn blocks_git_clean() {
316 let w = words("git clean -fd");
317 assert!(check_git_command(&w).is_some());
318 }
319
320 #[test]
323 fn blocks_git_branch_list() {
324 let w = words("git branch");
325 assert!(check_git_command(&w).is_some());
326 }
327
328 #[test]
329 fn blocks_git_branch_create() {
330 let w = words("git branch new-feature");
331 assert!(check_git_command(&w).is_some());
332 }
333
334 #[test]
335 fn blocks_git_branch_delete() {
336 let w = words("git branch -D feature-x");
337 assert!(check_git_command(&w).is_some());
338 }
339
340 #[test]
341 fn blocks_git_tag() {
342 let w = words("git tag v1.0.0");
343 assert!(check_git_command(&w).is_some());
344 }
345
346 #[test]
349 fn blocks_git_push() {
350 let w = words("git push origin main");
351 assert!(check_git_command(&w).is_some());
352 }
353
354 #[test]
355 fn blocks_git_push_force() {
356 let w = words("git push --force origin main");
357 assert!(check_git_command(&w).is_some());
358 }
359
360 #[test]
361 fn blocks_git_fetch() {
362 let w = words("git fetch origin");
363 assert!(check_git_command(&w).is_some());
364 }
365
366 #[test]
367 fn blocks_git_pull() {
368 let w = words("git pull --rebase origin main");
369 assert!(check_git_command(&w).is_some());
370 }
371
372 #[test]
373 fn blocks_git_clone() {
374 let w = words("git clone https://github.com/example/repo.git");
375 assert!(check_git_command(&w).is_some());
376 }
377
378 #[test]
379 fn blocks_git_init() {
380 let w = words("git init");
381 assert!(check_git_command(&w).is_some());
382 }
383
384 #[test]
385 fn blocks_git_remote() {
386 let w = words("git remote add origin https://github.com/example/repo.git");
387 assert!(check_git_command(&w).is_some());
388 }
389
390 #[test]
393 fn blocks_git_status() {
394 let w = words("git status");
395 assert!(check_git_command(&w).is_some());
396 }
397
398 #[test]
399 fn blocks_git_log() {
400 let w = words("git log --oneline -10");
401 assert!(check_git_command(&w).is_some());
402 }
403
404 #[test]
405 fn blocks_git_diff() {
406 let w = words("git diff --stat");
407 assert!(check_git_command(&w).is_some());
408 }
409
410 #[test]
411 fn blocks_git_show() {
412 let w = words("git show HEAD");
413 assert!(check_git_command(&w).is_some());
414 }
415
416 #[test]
417 fn blocks_git_blame() {
418 let w = words("git blame src/main.rs");
419 assert!(check_git_command(&w).is_some());
420 }
421
422 #[test]
425 fn blocks_git_worktree_add() {
426 let w = words("git worktree add ../other-dir");
427 assert!(check_git_command(&w).is_some());
428 }
429
430 #[test]
431 fn allows_git_worktree_list() {
432 let w = words("git worktree list");
433 assert!(check_git_command(&w).is_none());
434 }
435
436 #[test]
437 fn allows_git_worktree_repair() {
438 let w = words("git worktree repair");
439 assert!(check_git_command(&w).is_none());
440 }
441
442 #[test]
445 fn allows_gh_commands() {
446 let w = words("gh pr create --title test");
447 assert!(check_git_command(&w).is_none());
448 }
449
450 #[test]
451 fn allows_git_config() {
452 let w = words("git config user.name");
453 assert!(check_git_command(&w).is_none());
454 }
455
456 #[test]
457 fn allows_git_bisect() {
458 let w = words("git bisect start");
459 assert!(check_git_command(&w).is_none());
460 }
461
462 #[test]
465 fn handles_git_with_global_flags() {
466 let w = words("git -C /tmp/repo status");
467 assert!(check_git_command(&w).is_some());
468 }
469
470 #[test]
471 fn handles_git_no_pager() {
472 let w = words("git --no-pager log");
473 assert!(check_git_command(&w).is_some());
474 }
475
476 #[test]
479 fn allows_jj_git_push() {
480 let w = words("jj git push --bookmark main");
481 assert!(check_git_command(&w).is_none());
482 }
483
484 #[test]
485 fn allows_jj_git_fetch() {
486 let w = words("jj git fetch");
487 assert!(check_git_command(&w).is_none());
488 }
489
490 #[test]
491 fn allows_jj_git_clone() {
492 let w = words("jj git clone --colocate https://example.com/repo.git");
493 assert!(check_git_command(&w).is_none());
494 }
495
496 #[test]
497 fn allows_jj_git_remote() {
498 let w = words("jj git remote list");
499 assert!(check_git_command(&w).is_none());
500 }
501
502 #[test]
503 fn allows_jj_git_init() {
504 let w = words("jj git init --colocate");
505 assert!(check_git_command(&w).is_none());
506 }
507
508 #[test]
511 fn blocks_env_prefixed_git_push() {
512 let w = words("GIT_CONFIG_GLOBAL=~/.gitconfig.ai git push origin main");
513 assert!(check_git_command(&w).is_some());
514 }
515
516 #[test]
517 fn blocks_env_prefixed_git_commit() {
518 let w = words("FOO=bar BAZ=qux git commit -m test");
519 assert!(check_git_command(&w).is_some());
520 }
521
522 #[test]
525 fn status_suggestion_mentions_jj_status() {
526 let w = words("git status");
527 let blocked = check_git_command(&w).unwrap();
528 assert!(
529 blocked.suggestion.contains("jj status"),
530 "suggestion should mention jj status"
531 );
532 }
533
534 #[test]
535 fn diff_suggestion_covers_common_forms() {
536 let w = words("git diff");
537 let blocked = check_git_command(&w).unwrap();
538 assert!(
539 blocked.suggestion.contains("jj diff"),
540 "should mention jj diff"
541 );
542 assert!(
543 blocked.suggestion.contains("--from"),
544 "should mention --from/--to form"
545 );
546 assert!(
547 blocked.suggestion.contains("--git"),
548 "should mention --git for unified diff format"
549 );
550 assert!(
551 blocked.suggestion.contains("--stat"),
552 "should mention --stat"
553 );
554 }
555
556 #[test]
557 fn push_suggestion_mentions_bookmark() {
558 let w = words("git push origin main");
559 let blocked = check_git_command(&w).unwrap();
560 assert!(
561 blocked.suggestion.contains("jj git push"),
562 "should mention jj git push"
563 );
564 assert!(
565 blocked.suggestion.contains("--bookmark"),
566 "should mention --bookmark"
567 );
568 }
569
570 #[test]
571 fn branch_suggestion_covers_list_create_delete() {
572 let w = words("git branch");
573 let blocked = check_git_command(&w).unwrap();
574 assert!(
575 blocked.suggestion.contains("bookmark list"),
576 "should cover list"
577 );
578 assert!(
579 blocked.suggestion.contains("bookmark create"),
580 "should cover create"
581 );
582 assert!(
583 blocked.suggestion.contains("bookmark delete"),
584 "should cover delete"
585 );
586 }
587}