Skip to main content

agent_shell_parser/
guard.rs

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    // History / state-modifying
22    ("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    // Navigation / working copy
58    ("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    // Staging / file tracking
69    ("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 / bookmark management
90    ("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    // Remote operations
101    ("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    // Repository setup
117    ("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    // Informational
133    ("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    // Special case: git worktree (allow list/repair, block others)
174    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    // --- Destructive / state-modifying ---
219
220    #[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    // --- Navigation / working copy ---
269
270    #[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    // --- Staging / file tracking ---
295
296    #[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    // --- Branch / bookmark management ---
321
322    #[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    // --- Remote operations ---
347
348    #[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    // --- Informational ---
391
392    #[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    // --- Worktree (selective) ---
423
424    #[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    // --- Allowed through (no jj equivalent) ---
443
444    #[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    // --- Flag handling ---
463
464    #[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    // --- jj git subcommands must not be blocked ---
477
478    #[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    // --- Env-var-prefixed git commands should still be blocked ---
509
510    #[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    // --- Suggestion quality ---
523
524    #[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}