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 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    // Special case: git worktree (allow list/repair, block others)
171    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    // --- Destructive / state-modifying ---
216
217    #[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    // --- Navigation / working copy ---
266
267    #[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    // --- Staging / file tracking ---
292
293    #[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    // --- Branch / bookmark management ---
318
319    #[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    // --- Remote operations ---
344
345    #[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    // --- Informational ---
388
389    #[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    // --- Worktree (selective) ---
420
421    #[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    // --- Allowed through (no jj equivalent) ---
440
441    #[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    // --- Flag handling ---
460
461    #[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    // --- Suggestion quality ---
474
475    #[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}