Repository: firebase/firebase-tools (ts)

Issue:
--except is ignored during hosting deployment
<!-- DO NOT DELETE
validate_template=true
template_path=.github/ISSUE_TEMPLATE/bug_report.md
-->

<!--
Thank you for contributing to the Firebase community!

Think you found a bug?
=======================
Yeah, we're definitely not perfect! Please use this template and include a minimal repro when opening the issue. If you know how to solve the issue, please create a Pull Request, and we'd be happy to review it!

Have a feature request?
========================
Great, we love hearing how we can improve our products! However, do not use this template to submit a feature request. Please submit your feature requests to: https://firebase.google.com/support/contact/bugs-features/

Have a usage question?
=======================
We get lots of those and we love helping you, but GitHub is not the best place for them and they will be closed. Please take a look at the guide first: https://firebase.google.com/docs/cli/

If the official documentation doesn't help, try asking through our official support channel: https://firebase.google.com/support/

Additional locations to check for solutions or assistance from the community:
- Stack Overflow: https://stackoverflow.com/
- Firebase Slack Community: https://firebase.community/

*Please avoid duplicate posting across multiple channels!*
-->

### Environment info

<!-- What version of the Firebase CLI (`firebase-tools`) are you using? Note that your issue may already be fixed in the latest versions. The latest version can be found at https://github.com/firebase/firebase-tools/releases -->

<!-- Output of `firebase --version` -->

**firebase-tools:** 9.11.0

<!-- e.g. macOS, Windows, Ubuntu -->

**Platform:** macOS

### Test case

Hi all,
`firebase deploy --only hosting:@my-project/my-app` works as expected, however `firebase deploy --except hosting:@my-project/my-app` does not. Instead, it ignores the `--except` and deploys all the websites.

As a temporary workaround I'm doing the following:

```
yarn firebase deploy --except hosting
yarn firebase deploy --only hosting:@my-project/my-app1,hosting:@my-project/my-app2
```

but it's too verbose and unnecessary complex.


Pull request:
adds filterExcept to normalizedHostingConfigs
<!--

Thank you for contributing to the Firebase community! Please fill out the form below.

Run the linter and test suite
==============================
Run `npm test` to make sure your changes compile properly and the tests all pass on your local machine. We've hooked up this repo with continuous integration to double check those things for you.

-->

### Description

Adds support for `--except` when deploying to Firebase Hosting.

fixes #3397

<!-- Are you fixing a bug? Implementing a new feature? Make sure we have the context around your change. Link to other relevant issues or pull requests. -->

### Scenarios Tested

```
» firebase deploy --only hosting

=== Deploying to 'site'...

i  deploying hosting
i  hosting[site]: beginning deploy...
i  hosting[site]: found 17 files in public/
✔  hosting[site]: file upload complete
i  hosting[OTHER-SITE]: beginning deploy...
i  hosting[OTHER-SITE]: found 16 files in public/
✔  hosting[OTHER-SITE]: file upload complete
i  hosting[site]: finalizing version...
i  hosting[OTHER-SITE]: finalizing version...
✔  hosting[OTHER-SITE]: version finalized
i  hosting[OTHER-SITE]: releasing new version...
✔  hosting[site]: version finalized
i  hosting[site]: releasing new version...
✔  hosting[OTHER-SITE]: release complete
✔  hosting[site]: release complete

✔  Deploy complete!
```

`other` is the target for `OTHER-SITE`. I also have some functions...

```
» firebase deploy --except functions,hosting:other

=== Deploying to 'site'...

i  deploying hosting
i  hosting[site]: beginning deploy...
i  hosting[site]: found 17 files in public/
✔  hosting[site]: file upload complete
i  hosting[site]: finalizing version...
✔  hosting[site]: version finalized
i  hosting[site]: releasing new version...
✔  hosting[site]: release complete

✔  Deploy complete!
```



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6e6ebade9..2036dc8b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,1 +1,2 @@
-- Fix Auth Emulator errors when importing many users (#3577).
+- Fixes Auth Emulator errors when importing many users. (#3577)
+- Fixes support for `--except` flag when used for deploying Hosting. (#3397)
diff --git a/src/hosting/normalizedHostingConfigs.ts b/src/hosting/normalizedHostingConfigs.ts
index 1cb9decea..f2833e066 100644
--- a/src/hosting/normalizedHostingConfigs.ts
+++ b/src/hosting/normalizedHostingConfigs.ts
@@ -56,6 +56,30 @@ function filterOnly(configs: HostingConfig[], onlyString: string): HostingConfig
   return filteredConfigs;
 }
 
+function filterExcept(configs: HostingConfig[], exceptOption: string): HostingConfig[] {
+  if (!exceptOption) {
+    return configs;
+  }
+
+  const exceptTargets = exceptOption.split(",");
+  if (exceptTargets.includes("hosting")) {
+    return [];
+  }
+
+  const exceptValues = new Set(
+    exceptTargets.filter((t) => t.startsWith("hosting:")).map((t) => t.replace("hosting:", ""))
+  );
+
+  const filteredConfigs: HostingConfig[] = [];
+  for (const c of configs) {
+    if (!(exceptValues.has(c.site) || exceptValues.has(c.target))) {
+      filteredConfigs.push(c);
+    }
+  }
+
+  return filteredConfigs;
+}
+
 /**
  * Normalize options to HostingConfig array.
  * @param cmdOptions the Firebase CLI options object.
@@ -89,7 +113,9 @@ export function normalizedHostingConfigs(
     }
   }
 
-  const hostingConfigs: HostingConfig[] = filterOnly(configs, cmdOptions.only);
+  // filter* functions check if the strings are empty for us.
+  let hostingConfigs: HostingConfig[] = filterOnly(configs, cmdOptions.only);
+  hostingConfigs = filterExcept(hostingConfigs, cmdOptions.except);
 
   if (options.resolveTargets) {
     for (const cfg of hostingConfigs) {


Test patch:
diff --git a/src/test/hosting/normalizedHostingConfigs.spec.ts b/src/test/hosting/normalizedHostingConfigs.spec.ts
index e8d58bcae..7b2df9678 100644
--- a/src/test/hosting/normalizedHostingConfigs.spec.ts
+++ b/src/test/hosting/normalizedHostingConfigs.spec.ts
@@ -184,4 +184,104 @@ describe("normalizedHostingConfigs", () => {
       });
     }
   });
+
+  describe("with an except parameter, resolving targets", () => {
+    const DEFAULT_SITE = "default-hosting-site";
+    const TARGETED_SITE = "targeted-site";
+    const baseConfig = { public: "public", ignore: ["firebase.json"] };
+    const tests = [
+      {
+        desc: "a normal hosting config, omitting the default site",
+        cfg: Object.assign({}, baseConfig),
+        except: `hosting:${DEFAULT_SITE}`,
+        want: [],
+      },
+      {
+        desc: "a hosting config with multiple sites, no targets, omitting the second site",
+        cfg: [
+          Object.assign({}, baseConfig, { site: DEFAULT_SITE }),
+          Object.assign({}, baseConfig, { site: "different-site" }),
+        ],
+        except: `hosting:different-site`,
+        want: [Object.assign({}, baseConfig, { site: DEFAULT_SITE })],
+      },
+      {
+        desc: "a normal hosting config with a target, omitting the target",
+        cfg: Object.assign({}, baseConfig, { target: "main" }),
+        except: "hosting:main",
+        want: [],
+      },
+      {
+        desc: "a hosting config with multiple targets, omitting one",
+        cfg: [
+          Object.assign({}, baseConfig, { target: "t-one" }),
+          Object.assign({}, baseConfig, { target: "t-two" }),
+        ],
+        except: "hosting:t-two",
+        want: [Object.assign({}, baseConfig, { target: "t-one", site: TARGETED_SITE })],
+      },
+      {
+        desc: "a hosting config with multiple targets, omitting all hosting",
+        cfg: [
+          Object.assign({}, baseConfig, { target: "t-one" }),
+          Object.assign({}, baseConfig, { target: "t-two" }),
+        ],
+        except: "hosting",
+        want: [],
+      },
+      {
+        desc: "a hosting config with multiple targets, omitting an invalid target",
+        cfg: [
+          Object.assign({}, baseConfig, { target: "t-one" }),
+          Object.assign({}, baseConfig, { target: "t-two" }),
+        ],
+        except: "hosting:t-three",
+        want: [
+          Object.assign({}, baseConfig, { target: "t-one", site: TARGETED_SITE }),
+          Object.assign({}, baseConfig, { target: "t-two", site: TARGETED_SITE }),
+        ],
+      },
+      {
+        desc: "a hosting config with multiple targets, with multiple matching targets",
+        cfg: [
+          Object.assign({}, baseConfig, { target: "t-one" }),
+          Object.assign({}, baseConfig, { target: "t-one" }),
+          Object.assign({}, baseConfig, { target: "t-other" }),
+        ],
+        except: "hosting:t-other",
+        targetedSites: [TARGETED_SITE, TARGETED_SITE],
+        wantErr: /Hosting target.+t-one.+linked to multiple sites/,
+      },
+      {
+        desc: "a hosting config with multiple sites but no targets, only all hosting",
+        cfg: [Object.assign({}, baseConfig), Object.assign({}, baseConfig)],
+        except: "hosting:site",
+        wantErr: /Must supply either "site" or "target"/,
+      },
+    ];
+
+    for (const t of tests) {
+      it(`should be able to parse ${t.desc}`, () => {
+        if (!Array.isArray(t.targetedSites)) {
+          t.targetedSites = [TARGETED_SITE];
+        }
+        const cmdConfig = {
+          site: DEFAULT_SITE,
+          except: t.except,
+          config: { get: () => t.cfg },
+          rc: { requireTarget: () => t.targetedSites },
+        };
+
+        if (t.wantErr) {
+          expect(() => normalizedHostingConfigs(cmdConfig, { resolveTargets: true })).to.throw(
+            FirebaseError,
+            t.wantErr
+          );
+        } else {
+          const got = normalizedHostingConfigs(cmdConfig, { resolveTargets: true });
+          expect(got).to.deep.equal(t.want);
+        }
+      });
+    }
+  });
 });