amont_agent_process/
push_preview.rs1use crate::preview::Needs;
38use amont_agent_guard::rules::{
39 Confirmed, Context, Evidence, Examine, Finding, Rule, Stance, Trend,
40};
41use amont_agent_guard::shell::Parsed;
42
43pub const RULE: Rule = Rule {
44 id: "push-preview",
45 prevents: "A push that publishes interface changes no approved preview covers",
46 irreversible: false,
47 default_stance: Stance::Advise,
52 max_stance: Stance::Deny,
53 evidence: Evidence {
54 per_1000: 0.0,
55 measured: "2026-09-28",
56 trend: Trend::Rare,
57 },
58 examine: Examine::Legacy(examine),
59 confirm: Some(confirm),
60};
61
62fn examine(parsed: &Parsed) -> Option<Finding> {
63 let cmd = amont_agent_guard::push_target::find(parsed)?;
64 if cmd.is_dry_run() || cmd.has_short('n') {
65 return None;
66 }
67 if cmd
71 .operands()
72 .iter()
73 .skip(2)
74 .any(|w| to_default_branch(&w.text))
75 {
76 return None;
77 }
78 Some(Finding {
79 reason: "This push would publish interface changes that no approved preview covers."
80 .to_string(),
81 remedy: "Verify the final commit in a real browser, serve the clean worktree, then run \
82 `amont-agent preview register --url <url> --guide <guide.md outside the worktree>` \
83 in the foreground as the last command of its line (see `amont-agent preview --help` for the guide's \
84 sections), open the app at the first step's state and the rendered page, and, in the same turn, ask \
85 the marked question starting with its `question_prefix` (`[preview <id>] <label>`, options exactly \
86 Approve / Request changes / Hold). \
87 Push after the person approves. A push shape this guard cannot read is held under deny: \
88 push the branch explicitly (`git push <remote> <branch>`)."
89 .to_string(),
90 span: cmd.at..cmd.end,
91 })
92}
93
94fn to_default_branch(refspec: &str) -> bool {
95 let dst = refspec.rsplit(':').next().unwrap_or(refspec);
96 let dst = dst.strip_prefix('+').unwrap_or(dst);
97 let dst = dst.strip_prefix("refs/heads/").unwrap_or(dst);
98 matches!(dst, "main" | "master")
99}
100
101fn confirm(ctx: &Context, _finding: &Finding) -> Confirmed {
102 let Some(cmd) = amont_agent_guard::push_target::find(ctx.parsed) else {
103 return Confirmed::No("the push could not be read a second time");
104 };
105 let stance = amont_agent_guard::stance::resolve(&RULE);
106 match crate::preview::needs_preview(ctx, cmd, stance) {
107 Ok(Needs::Unapproved { mockup: true }) => Confirmed::YesAt(Stance::Deny),
111 Ok(Needs::Unapproved { mockup: false }) => Confirmed::Yes,
112 Ok(Needs::Unreadable(shape)) => Confirmed::YesSaying {
115 floor: None,
116 ceiling: None,
117 reason: format!("This push cannot be read, so under deny it is held: {shape}."),
118 excerpt: format!("unreadable: {shape}"),
119 remedy: Some(
121 "Push a branch or an existing tag by name, in its own command \
122 (`git push <remote> <ref>`); this guard then reads what it publishes. \
123 A tag needs no preview."
124 .to_string(),
125 ),
126 },
127 Err(why) => Confirmed::No(why),
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use amont_agent_guard::shell::lex;
135
136 fn fires(command: &str) -> bool {
137 examine(&lex(command)).is_some()
138 }
139
140 #[test]
141 fn a_push_is_examined() {
142 assert!(fires("git push -u origin feat/x"));
143 assert!(fires("cd ../wt && git push origin HEAD:feat/x"));
144 assert!(fires("git -C ../wt push origin feat/x"));
145 }
146
147 #[test]
148 fn a_dry_run_and_other_commands_are_not() {
149 assert!(!fires("git push --dry-run origin feat/x"));
150 assert!(!fires("git push -n origin feat/x"));
151 assert!(!fires("git pull"));
152 assert!(!fires("echo git push"));
153 assert!(!fires("git push origin main"));
154 assert!(!fires("git push origin HEAD:refs/heads/master"));
155 }
156
157 #[test]
158 fn the_span_is_the_push_clause() {
159 let command = "npm test && git push -u origin feat/x";
160 let f = examine(&lex(command)).expect("fires");
161 assert_eq!(command[f.span].trim(), "git push -u origin feat/x");
162 }
163}