Repository: c0fec0de/git-ws (language: python)

Issue:
Clarify/refine group filter selection
Currently, we have the following for the group filter/selection:

```
## Groups are optional by default.
## If a dependency belongs to a group, it becomes optional likewise.
## Groups can be later on selected/deselected by '+group' or '-group'.
## An optional group can be selected by '+group',
## a non-optional group can be deselected by '-group'.
## Deselection has higher priority than selection.
```

This means that a filter like `+my_group,-my_group,+my_group` would result in `my_group` not being selected. This is a bit counter intuitive, especially when comparing the mechanism to other similar ones. For example, applications that consume options and especially flags often allow to set a flag via `--feature` and disable it via `--no-feature`. The arguments are evaluated *in order* and and basically whatever appears last wins. This probably also makes more sense for the filtering (especially in cases where users might generate the filter expression from something else).


Discussion / hints:

Fix patch:
diff --git a/docs/static/cli.add.txt b/docs/static/cli.add.txt
index a0292ae..d5bbe2c 100644
--- a/docs/static/cli.add.txt
+++ b/docs/static/cli.add.txt
@@ -3,6 +3,10 @@ Usage: git-ws add [OPTIONS] [PATHS]...
   Run 'git add' on paths and choose the right git clone automatically.
 
 Options:
-  -f, --force  allow adding otherwise ignored files
-  -A, --all    add changes from all tracked and untracked files
-  -h, --help   Show this message and exit.
+  -f, --force                allow adding otherwise ignored files
+  -A, --all                  add changes from all tracked and untracked files
+  -M, --manifest FILE        Manifest file. Initial/Configuration settings by
+                             default.
+  -G, --group-filter FILTER  Group Filtering. TODO: more details. Initial
+                             clone/init filter settings by default.
+  -h, --help                 Show this message and exit.
diff --git a/docs/static/cli.checkout.txt b/docs/static/cli.checkout.txt
index 8642177..200cd62 100644
--- a/docs/static/cli.checkout.txt
+++ b/docs/static/cli.checkout.txt
@@ -6,5 +6,9 @@ Usage: git-ws checkout [OPTIONS] [PATHS]...
   Checkout all clones to their manifest revision, if no paths are given.
 
 Options:
-  -f, --force  Enfore operation.
-  -h, --help   Show this message and exit.
+  -f, --force                Enfore operation.
+  -M, --manifest FILE        Manifest file. Initial/Configuration settings by
+                             default.
+  -G, --group-filter FILTER  Group Filtering. TODO: more details. Initial
+                             clone/init filter settings by default.
+  -h, --help                 Show this message and exit.
diff --git a/docs/static/cli.clone.txt b/docs/static/cli.clone.txt
index 415116c..6874a26 100644
--- a/docs/static/cli.clone.txt
+++ b/docs/static/cli.clone.txt
@@ -3,9 +3,9 @@ Usage: git-ws clone [OPTIONS] URL [PATH]...
   Create a git clone and initialize Git Workspace.
 
 Options:
-  -M, --manifest FILE  Manifest file. 'git-ws.toml' by default.
-  -G, --groups TEXT    Group Filtering. TODO: more details. The setting
-                       becomes default for all successive runs.
-  -U, --update         Run 'git ws update' too.
-  -f, --force          Enfore operation.
-  -h, --help           Show this message and exit.
+  -M, --manifest FILE        Manifest file. 'git-ws.toml' by default.
+  -G, --group-filter FILTER  Group Filtering. TODO: more details. The setting
+                             becomes default for all successive runs.
+  -U, --update               Run 'git ws update' too.
+  -f, --force                Enfore operation.
+  -h, --help                 Show this message and exit.
diff --git a/docs/static/cli.commit.txt b/docs/static/cli.commit.txt
index 44ade6c..64300d9 100644
--- a/docs/static/cli.commit.txt
+++ b/docs/static/cli.commit.txt
@@ -3,6 +3,10 @@ Usage: git-ws commit [OPTIONS] [PATHS]...
   Run 'git commit' on paths and choose the right git clone automatically.
 
 Options:
-  -m, --message TEXT  commit message
-  -a, --all           commit all changed files
-  -h, --help          Show this message and exit.
+  -m, --message TEXT         commit message
+  -a, --all                  commit all changed files
+  -M, --manifest FILE        Manifest file. Initial/Configuration settings by
+                             default.
+  -G, --group-filter FILTER  Group Filtering. TODO: more details. Initial
+                             clone/init filter settings by default.
+  -h, --help                 Show this message and exit.
diff --git a/docs/static/cli.fetch.txt b/docs/static/cli.fetch.txt
index 2879aaa..53e4b2e 100644
--- a/docs/static/cli.fetch.txt
+++ b/docs/static/cli.fetch.txt
@@ -5,10 +5,10 @@ Usage: git-ws fetch [OPTIONS]
   This command behaves identical to `git ws foreach -- git fetch`.
 
 Options:
-  -P, --project DIRECTORY  Project path to operate on only. All by default.
-                           This option can be specified multiple times.
-  -M, --manifest FILE      Manifest file. Initial clone/init settings by
-                           default.
-  -G, --groups TEXT        Group Filtering. TODO: more details. Initial
-                           clone/init filter settings by default.
-  -h, --help               Show this message and exit.
+  -P, --project DIRECTORY    Project path to operate on only. All by default.
+                             This option can be specified multiple times.
+  -M, --manifest FILE        Manifest file. Initial/Configuration settings by
+                             default.
+  -G, --group-filter FILTER  Group Filtering. TODO: more details. Initial
+                             clone/init filter settings by default.
+  -h, --help                 Show this message and exit.
diff --git a/docs/static/cli.foreach.txt b/docs/static/cli.foreach.txt
index b6aff7e..83a72f1 100644
--- a/docs/static/cli.foreach.txt
+++ b/docs/static/cli.foreach.txt
@@ -6,12 +6,12 @@ Usage: git-ws foreach [OPTIONS] [COMMAND]...
   forwarded to the `command` (i.e. `git ws foreach -- ls -l`)
 
 Options:
-  -P, --project DIRECTORY  Project path to operate on only. All by default.
-                           This option can be specified multiple times.
-  -M, --manifest FILE      Manifest file. Initial clone/init settings by
-                           default.
-  -G, --groups TEXT        Group Filtering. TODO: more details. Initial
-                           clone/init filter settings by default.
-  -R, --reverse            Operate in Reverse Order. Start with last
-                           dependency instead of main repository.
-  -h, --help               Show this message and exit.
+  -P, --project DIRECTORY    Project path to operate on only. All by default.
+                             This option can be specified multiple times.
+  -M, --manifest FILE        Manifest file. Initial/Configuration settings by
+                             default.
+  -G, --group-filter FILTER  Group Filtering. TODO: more details. Initial
+                             clone/init filter settings by default.
+  -R, --reverse              Operate in Reverse Order. Start with last
+                             dependency instead of main repository.
+  -h, --help                 Show this message and exit.
diff --git a/docs/static/cli.git.txt b/docs/static/cli.git.txt
index 248e5f2..7dd1c98 100644
--- a/docs/static/cli.git.txt
+++ b/docs/static/cli.git.txt
@@ -5,12 +5,12 @@ Usage: git-ws git [OPTIONS] [COMMAND]...
   This command behaves identical to `git ws foreach -- git`.
 
 Options:
-  -P, --project DIRECTORY  Project path to operate on only. All by default.
-                           This option can be specified multiple times.
-  -M, --manifest FILE      Manifest file. Initial clone/init settings by
-                           default.
-  -G, --groups TEXT        Group Filtering. TODO: more details. Initial
-                           clone/init filter settings by default.
-  -R, --reverse            Operate in Reverse Order. Start with last
-                           dependency instead of main repository.
-  -h, --help               Show this message and exit.
+  -P, --project DIRECTORY    Project path to operate on only. All by default.
+                             This option can be specified multiple times.
+  -M, --manifest FILE        Manifest file. Initial/Configuration settings by
+                             default.
+  -G, --group-filter FILTER  Group Filtering. TODO: more details. Initial
+                             clone/init filter settings by default.
+  -R, --reverse              Operate in Reverse Order. Start with last
+                             dependency instead of main repository.
+  -h, --help                 Show this message and exit.
diff --git a/docs/static/cli.info.dep-tree.txt b/docs/static/cli.info.dep-tree.txt
index ce28870..88fda13 100644
--- a/docs/static/cli.info.dep-tree.txt
+++ b/docs/static/cli.info.dep-tree.txt
@@ -7,7 +7,13 @@ Usage: git-ws info dep-tree [OPTIONS]
 
   $ git ws info dep-tree --dot | dot -Tpng > dep-tree.png
 
+  $ git ws info dep-tree --dot | dot -Tsvg > dep-tree.svg
+
 Options:
-  -M, --manifest FILE  Manifest file. Initial clone/init settings by default.
-  -d, --dot            Export DOT Format to be forwarded to graphviz.
-  -h, --help           Show this message and exit.
+  -M, --manifest FILE        Manifest file. Initial/Configuration settings by
+                             default.
+  -G, --group-filter FILTER  Group Filtering. TODO: more details. Initial
+                             clone/init filter settings by default.
+  -d, --dot                  Export DOT Format to be forwarded to graphviz.
+  -p, --primary              Display primary dependencies only.
+  -h, --help                 Show this message and exit.
diff --git a/docs/static/cli.init.txt b/docs/static/cli.init.txt
index dfe2b90..fb230d4 100644
--- a/docs/static/cli.init.txt
+++ b/docs/static/cli.init.txt
@@ -6,9 +6,9 @@ Usage: git-ws init [OPTIONS] [PATH]...
   created by 'git init' or 'git clone'.
 
 Options:
-  -M, --manifest FILE  Manifest file. 'git-ws.toml' by default.
-  -G, --groups TEXT    Group Filtering. TODO: more details. The setting
-                       becomes default for all successive runs.
-  -U, --update         Run 'git ws update' too.
-  -f, --force          Enfore operation.
-  -h, --help           Show this message and exit.
+  -M, --manifest FILE        Manifest file. 'git-ws.toml' by default.
+  -G, --group-filter FILTER  Group Filtering. TODO: more details. The setting
+                             becomes default for all successive runs.
+  -U, --update               Run 'git ws update' too.
+  -f, --force                Enfore operation.
+  -h, --help                 Show this message and exit.
diff --git a/docs/static/cli.pull.txt b/docs/static/cli.pull.txt
index 445fa22..d7ca4aa 100644
--- a/docs/static/cli.pull.txt
+++ b/docs/static/cli.pull.txt
@@ -5,10 +5,10 @@ Usage: git-ws pull [OPTIONS]
   This command behaves identical to `git ws foreach -- git pull`.
 
 Options:
-  -P, --project DIRECTORY  Project path to operate on only. All by default.
-                           This option can be specified multiple times.
-  -M, --manifest FILE      Manifest file. Initial clone/init settings by
-                           default.
-  -G, --groups TEXT        Group Filtering. TODO: more details. Initial
-                           clone/init filter settings by default.
-  -h, --help               Show this message and exit.
+  -P, --project DIRECTORY    Project path to operate on only. All by default.
+                             This option can be specified multiple times.
+  -M, --manifest FILE        Manifest file. Initial/Configuration settings by
+                             default.
+  -G, --group-filter FILTER  Group Filtering. TODO: more details. Initial
+                             clone/init filter settings by default.
+  -h, --help                 Show this message and exit.
diff --git a/docs/static/cli.push.txt b/docs/static/cli.push.txt
index 52cef9e..bbf8ccd 100644
--- a/docs/static/cli.push.txt
+++ b/docs/static/cli.push.txt
@@ -5,10 +5,10 @@ Usage: git-ws push [OPTIONS]
   This command behaves identical to `git ws foreach --reverse -- git push`.
 
 Options:
-  -P, --project DIRECTORY  Project path to operate on only. All by default.
-                           This option can be specified multiple times.
-  -M, --manifest FILE      Manifest file. Initial clone/init settings by
-                           default.
-  -G, --groups TEXT        Group Filtering. TODO: more details. Initial
-                           clone/init filter settings by default.
-  -h, --help               Show this message and exit.
+  -P, --project DIRECTORY    Project path to operate on only. All by default.
+                             This option can be specified multiple times.
+  -M, --manifest FILE        Manifest file. Initial/Configuration settings by
+                             default.
+  -G, --group-filter FILTER  Group Filtering. TODO: more details. Initial
+                             clone/init filter settings by default.
+  -h, --help                 Show this message and exit.
diff --git a/docs/static/cli.rebase.txt b/docs/static/cli.rebase.txt
index d4c3112..6530deb 100644
--- a/docs/static/cli.rebase.txt
+++ b/docs/static/cli.rebase.txt
@@ -5,10 +5,10 @@ Usage: git-ws rebase [OPTIONS]
   This command behaves identical to `git ws foreach -- git rebase`.
 
 Options:
-  -P, --project DIRECTORY  Project path to operate on only. All by default.
-                           This option can be specified multiple times.
-  -M, --manifest FILE      Manifest file. Initial clone/init settings by
-                           default.
-  -G, --groups TEXT        Group Filtering. TODO: more details. Initial
-                           clone/init filter settings by default.
-  -h, --help               Show this message and exit.
+  -P, --project DIRECTORY    Project path to operate on only. All by default.
+                             This option can be specified multiple times.
+  -M, --manifest FILE        Manifest file. Initial/Configuration settings by
+                             default.
+  -G, --group-filter FILTER  Group Filtering. TODO: more details. Initial
+                             clone/init filter settings by default.
+  -h, --help                 Show this message and exit.
diff --git a/docs/static/cli.reset.txt b/docs/static/cli.reset.txt
index b7fe04c..60e36e1 100644
--- a/docs/static/cli.reset.txt
+++ b/docs/static/cli.reset.txt
@@ -3,4 +3,8 @@ Usage: git-ws reset [OPTIONS] [PATHS]...
   Run 'git reset' on paths and choose the right git clone automatically.
 
 Options:
-  -h, --help  Show this message and exit.
+  -M, --manifest FILE        Manifest file. Initial/Configuration settings by
+                             default.
+  -G, --group-filter FILTER  Group Filtering. TODO: more details. Initial
+                             clone/init filter settings by default.
+  -h, --help                 Show this message and exit.
diff --git a/docs/static/cli.rm.txt b/docs/static/cli.rm.txt
index 1b7020e..ab23f93 100644
--- a/docs/static/cli.rm.txt
+++ b/docs/static/cli.rm.txt
@@ -3,7 +3,11 @@ Usage: git-ws rm [OPTIONS] [PATHS]...
   Run 'git rm' on paths and choose the right git clone automatically.
 
 Options:
-  -f, --force  Enfore operation.
-  --cached     only remove from index
-  -r           allow recursive removal
-  -h, --help   Show this message and exit.
+  -f, --force                Enfore operation.
+  --cached                   only remove from index
+  -r                         allow recursive removal
+  -M, --manifest FILE        Manifest file. Initial/Configuration settings by
+                             default.
+  -G, --group-filter FILTER  Group Filtering. TODO: more details. Initial
+                             clone/init filter settings by default.
+  -h, --help                 Show this message and exit.
diff --git a/docs/static/cli.status.txt b/docs/static/cli.status.txt
index e0354ac..fe3dfc3 100644
--- a/docs/static/cli.status.txt
+++ b/docs/static/cli.status.txt
@@ -3,5 +3,9 @@ Usage: git-ws status [OPTIONS] [PATHS]...
   Run 'git status' (displayed paths include the actual clone path).
 
 Options:
-  -b, --branch  show branch information
-  -h, --help    Show this message and exit.
+  -b, --branch               show branch information
+  -M, --manifest FILE        Manifest file. Initial/Configuration settings by
+                             default.
+  -G, --group-filter FILTER  Group Filtering. TODO: more details. Initial
+                             clone/init filter settings by default.
+  -h, --help                 Show this message and exit.
diff --git a/docs/static/cli.update.txt b/docs/static/cli.update.txt
index f0c84d5..6190e38 100644
--- a/docs/static/cli.update.txt
+++ b/docs/static/cli.update.txt
@@ -3,14 +3,14 @@ Usage: git-ws update [OPTIONS]
   Create/update all dependent git clones.
 
 Options:
-  -P, --project DIRECTORY  Project path to operate on only. All by default.
-                           This option can be specified multiple times.
-  -M, --manifest FILE      Manifest file. Initial clone/init settings by
-                           default.
-  -G, --groups TEXT        Group Filtering. TODO: more details. Initial
-                           clone/init filter settings by default.
-  -S, --skip-main          Skip Main Repository
-  --rebase                 Run 'git rebase' instead of 'git pull'
-  --prune                  Remove obsolete git clones
-  -f, --force              Enfore operation.
-  -h, --help               Show this message and exit.
+  -P, --project DIRECTORY    Project path to operate on only. All by default.
+                             This option can be specified multiple times.
+  -M, --manifest FILE        Manifest file. Initial/Configuration settings by
+                             default.
+  -G, --group-filter FILTER  Group Filtering. TODO: more details. Initial
+                             clone/init filter settings by default.
+  -S, --skip-main            Skip Main Repository
+  --rebase                   Run 'git rebase' instead of 'git pull'
+  --prune                    Remove obsolete git clones
+  -f, --force                Enfore operation.
+  -h, --help                 Show this message and exit.
diff --git a/docs/static/git-ws.toml b/docs/static/git-ws.toml
index b75f18f..52970d5 100644
--- a/docs/static/git-ws.toml
+++ b/docs/static/git-ws.toml
@@ -1,147 +1,27 @@
 version = "1.0"
 ##
-## Welcome to Git Workspace's Manifest. It actually contains 4 parts:
+## Git Workspace's Manifest. Please see the documentation at:
 ##
-## * Remotes
-## * Groups
-## * Defaults
-## * Dependencies
+## https://git-ws.readthedocs.io/en/latest/manual/manifest.html
 ##
-## =========
-##  Remotes
-## =========
-##
-## Remotes just refer to a directory with repositories.
-##
-## We support relative paths for dependencies. So, if your dependencies are next
-## to your repository, you might NOT need any remote.
-## In other terms: You only need remotes if your dependencies are located on
-## OTHER servers than your server with this manifest.
-##
-## Remotes have two attributes:
-## * name: Required. String.
-##         Name of the remote. Any valid string. Must be unique within your
-##         manifest.
-## * url-base: Required. String.
-##             URL Prefix. The project 'name' or 'sub-url' will be appended
-##             later-on.
-##
-# [[remotes]]
-# name = "myremote"
-# url-base = "https://github.com/myuser"
 
 
-## =========
-##  Groups
-## =========
-##
-## Groups structure dependencies.
-##
-## Groups are optional by default.
-## If a dependency belongs to a group, it becomes optional likewise.
-## Groups can be later on selected/deselected by '+group' or '-group'.
-## An optional group can be selected by '+group',
-## a non-optional group can be deselected by '-group'.
-## Deselection has higher priority than selection.
-##
-## Dependencies can refer to non-existing groups. You do NOT need to specify
-## all used groups.
-##
-## Groups have two attributes:
-## * name: Required. String.
-##         Name of the group. Any valid string. Must be unique within your
-##         manifest.
-## * optional: Optional. Bool. Default is True.
-##             Specifies if the group is optional. Meaning it must be selected
-##             explicitly. Otherwise the dependency is not added by default.
-##
-## The following lines set a group as non-optional.
-# [[groups]]
-# name = "test"
-# optional = false
+# group-filters = ["+test", "-doc", "+feature@path"]
+group-filters = []
 
 
-## ==========
-##  Defaults
-## ==========
-##
-## The 'defaults' section specifies default values for dependencies.
-##
-## * remote: Optional. String.
-##           Remote used as default.
-##           The 'remote' MUST be defined in the 'remotes' section above!
-## * revision: Optional. String.
-##             Revision used as default. Tag or Branch.
-##
-## NOTE: It is recommended to specify a default revision (i.e. 'main').
-##       If a dependency misses 'revision', GitWS will not take care about
-##       revision handling. This may lead to strange side-effects. You
-##       have been warned.
+# [[remotes]]
+# name = "myremote"
+# url-base = "https://github.com/myuser"
+
 
 [defaults]
 # remote = "myserver"
 # revision = "main"
+# groups = ["+test"]
+# with_groups = ["doc"]
 
 
-## ==============
-##  Dependencies
-## ==============
-##
-## The 'dependencies' section specifies all your git clones you need for your
-## project to operate.
-##
-## A dependency has the following attributes:
-## * name: Required. String.
-##         Just name your dependency. It is recommended to choose a
-##         unique name, but not a must.
-## * remote: Optional. String. Restricted (see RESTRICTIONS below).
-##           Remote Alias.
-##           The 'remote' MUST be defined in the 'remotes' section above!
-##           The 'remote' can also be specified in the 'defaults' section.
-## * sub-url: Optional. String. Default: '../{name}[.git]' (see NOTE1 below).
-##            Relative URL to 'url-base' of your specified 'remote'
-##            OR
-##            Relative URL to the URL of the repository containing this
-##            manifest.
-## * url: Optional. String. Restricted (see RESTRICTIONS below).
-##        Absolute URL to the dependent repository.
-## * revision: Optional. String.
-##             Revision to be checked out.
-##             If this attribute is left blank, GitWS does NOT manage the
-##             dependency revision (see NOTE2 below)!
-##             The 'revision' can also be specified in the 'defaults' section.
-## * path: Optional. String. Default is '{name}'.
-##         Project Filesystem Path. Relative to Workspace Root Directory.
-##         The dependency 'name' is used as default for 'path'.
-##         The 'path' MUST be unique within your manifest.
-## * manifest_path: Optional. String. Default: 'git-ws.toml'.
-##                   Path to manifest.
-##                   Relative to 'path'.
-##                   Avoid changing it! It is just additional effort.
-## * groups: Optional. List of Strings.
-##           Dependency Groups.
-##           Dependencies can be categorized into groups.
-##           Groups are optional by default. See 'groups' section above.
-##
-## NOTE1: 'sub-url' is '../{name}[.git]' by default. Meaning if the dependency
-##        is next to your repository containing this manifest, the dependency
-##        is automatically found.
-##        The '.git' suffix is appended if the repository containing this
-##        manifest uses a '.git' suffix.
-##
-## NOTE2: It is recommended to specify a revision (i.e. 'main') either
-##        explicitly or via the 'default' section.
-##        Without a 'revision' GitWS will not take care about revision
-##        handling. This may lead to strange side-effects.
-##        You have been warned.
-##
-## RESTRICTIONS:
-##
-## * `remote` and `url` are mutually exclusive.
-## * `url` and `sub-url` are likewise mutually exclusive
-## * `sub-url` requires a `remote`.
-##
-##
 ## A full flavored dependency using a 'remote':
 # [[dependencies]]
 # name = "myname"
diff --git a/gitws/__init__.py b/gitws/__init__.py
index 4d8601a..fb396d4 100644
--- a/gitws/__init__.py
+++ b/gitws/__init__.py
@@ -31,7 +31,21 @@
 
 from .appconfig import AppConfig, AppConfigLocation
 from .clone import Clone, map_paths
-from .datamodel import AppConfigData, Defaults, Group, Manifest, ManifestSpec, Project, ProjectSpec, Remote
+from .datamodel import (
+    AppConfigData,
+    Defaults,
+    Group,
+    GroupFilter,
+    GroupFilters,
+    Groups,
+    GroupSelect,
+    GroupSelects,
+    Manifest,
+    ManifestSpec,
+    Project,
+    ProjectSpec,
+    Remote,
+)
 from .exceptions import (
     GitCloneMissingError,
     GitCloneNotCleanError,
@@ -46,7 +60,6 @@
     UninitializedError,
     WorkspaceNotEmptyError,
 )
-from .filters import Filter
 from .git import Git
 from .gitws import GitWS
 from .iters import ManifestIter, ProjectIter
diff --git a/gitws/_cli/__init__.py b/gitws/_cli/__init__.py
index 3b00ee7..bf80f33 100644
--- a/gitws/_cli/__init__.py
+++ b/gitws/_cli/__init__.py
@@ -32,7 +32,7 @@
 from .manifest import manifest
 from .options import (
     force_option,
-    groups_option,
+    group_filters_option,
     manifest_option,
     path_option,
     paths_argument,
@@ -67,11 +67,11 @@ def main(ctx=None, verbose=0):
 @main.command()
 @path_option()
 @manifest_option(initial=True)
-@groups_option(initial=True)
+@group_filters_option(initial=True)
 @update_option()
 @force_option()
 @pass_context
-def init(context, path=None, manifest_path=None, groups=None, update: bool = False, force: bool = False):
+def init(context, path=None, manifest_path=None, group_filters=None, update: bool = False, force: bool = False):
     """
     Initialize Git Workspace.
 
@@ -80,15 +80,14 @@ def init(context, path=None, manifest_path=None, groups=None, update: bool = Fal
     """
     with exceptionhandling(context):
         path = process_path(path)
-        gws = GitWS.init(main_path=path, manifest_path=manifest_path, groups=groups, force=force, secho=context.secho)
+        gws = GitWS.init(
+            main_path=path, manifest_path=manifest_path, group_filters=group_filters, force=force, secho=context.secho
+        )
         click.secho(f"Workspace initialized at {str(resolve_relative(gws.path))!r}.")
         if update:
             gws.update(skip_main=True)
         else:
-            click.secho(
-                "Please continue with:\n\n    git ws update\n",
-                fg=COLOR_INFO,
-            )
+            click.secho("Please continue with:\n\n    git ws update\n", fg=COLOR_INFO)
 
 
 @main.command()
@@ -107,18 +106,23 @@ def deinit(context):
 @click.argument("url")
 @path_option()
 @manifest_option(initial=True)
-@groups_option(initial=True)
+@group_filters_option(initial=True)
 @update_option()
 @force_option()
 @pass_context
-def clone(context, url, path=None, manifest_path=None, groups=None, update: bool = False, force: bool = False):
+def clone(context, url, path=None, manifest_path=None, group_filters=None, update: bool = False, force: bool = False):
     """
     Create a git clone and initialize Git Workspace.
     """
     with exceptionhandling(context):
         path = process_path(path)
         gws = GitWS.clone(
-            url, main_path=path, manifest_path=manifest_path, groups=groups, force=force, secho=context.secho
+            url,
+            main_path=path,
+            manifest_path=manifest_path,
+            group_filters=group_filters,
+            force=force,
+            secho=context.secho,
         )
         if update:
             gws.update(skip_main=True)
@@ -133,7 +137,7 @@ def clone(context, url, path=None, manifest_path=None, groups=None, update: bool
 @main.command()
 @projects_option()
 @manifest_option()
-@groups_option()
+@group_filters_option()
 @click.option("--skip-main", "-S", is_flag=True, default=False, help="Skip Main Repository")
 @click.option("--rebase", is_flag=True, default=False, help="Run 'git rebase' instead of 'git pull'")
 @click.option("--prune", is_flag=True, default=False, help="Remove obsolete git clones")
@@ -143,7 +147,7 @@ def update(
     context,
     projects=None,
     manifest_path=None,
-    groups=None,
+    group_filters=None,
     skip_main: bool = False,
     rebase: bool = False,
     prune: bool = False,
@@ -151,11 +155,9 @@ def update(
 ):
     """Create/update all dependent git clones."""
     with exceptionhandling(context):
-        gws = GitWS.from_path(manifest_path=manifest_path, secho=context.secho)
+        gws = GitWS.from_path(manifest_path=manifest_path, group_filters=group_filters, secho=context.secho)
         gws.update(
             project_paths=projects,
-            manifest_path=manifest_path,
-            groups=groups,
             skip_main=skip_main,
             rebase=rebase,
             prune=prune,
@@ -166,102 +168,104 @@ def update(
 @main.command()
 @projects_option()
 @manifest_option()
-@groups_option()
+@group_filters_option()
 @reverse_option()
 @click.argument("command", nargs=-1, type=click.UNPROCESSED)
 @pass_context
-def git(context, command, projects=None, manifest_path=None, groups=None, reverse=False):
+def git(context, command, projects=None, manifest_path=None, group_filters=None, reverse=False):
     """
     Run git command on projects.
 
     This command behaves identical to `git ws foreach -- git`.
     """
     with exceptionhandling(context):
-        gws = GitWS.from_path(manifest_path=manifest_path, secho=context.secho)
-        gws.run_foreach(("git",) + command, project_paths=projects, groups=groups, reverse=reverse)
+        gws = GitWS.from_path(manifest_path=manifest_path, group_filters=group_filters, secho=context.secho)
+        gws.run_foreach(("git",) + command, project_paths=projects, reverse=reverse)
 
 
 @main.command()
 @projects_option()
 @manifest_option()
-@groups_option()
+@group_filters_option()
 @pass_context
-def fetch(context, projects=None, manifest_path=None, groups=None):
+def fetch(context, projects=None, manifest_path=None, group_filters=None):
     """
     Run 'git fetch' on projects.
 
     This command behaves identical to `git ws foreach -- git fetch`.
     """
     with exceptionhandling(context):
-        gws = GitWS.from_path(manifest_path=manifest_path, secho=context.secho)
-        gws.run_foreach(("git", "fetch"), project_paths=projects, groups=groups)
+        gws = GitWS.from_path(manifest_path=manifest_path, group_filters=group_filters, secho=context.secho)
+        gws.run_foreach(("git", "fetch"), project_paths=projects)
 
 
 @main.command()
 @projects_option()
 @manifest_option()
-@groups_option()
+@group_filters_option()
 @pass_context
-def pull(context, projects=None, manifest_path=None, groups=None):
+def pull(context, projects=None, manifest_path=None, group_filters=None):
     """
     Run 'git pull' on projects.
 
     This command behaves identical to `git ws foreach -- git pull`.
     """
     with exceptionhandling(context):
-        gws = GitWS.from_path(manifest_path=manifest_path, secho=context.secho)
-        gws.run_foreach(("git", "pull"), project_paths=projects, groups=groups)
+        gws = GitWS.from_path(manifest_path=manifest_path, group_filters=group_filters, secho=context.secho)
+        gws.run_foreach(("git", "pull"), project_paths=projects)
 
 
 @main.command()
 @projects_option()
 @manifest_option()
-@groups_option()
+@group_filters_option()
 @pass_context
-def push(context, projects=None, manifest_path=None, groups=None):
+def push(context, projects=None, manifest_path=None, group_filters=None):
     """
     Run 'git push' on projects (in reverse order).
 
     This command behaves identical to `git ws foreach --reverse -- git push`.
     """
     with exceptionhandling(context):
-        gws = GitWS.from_path(manifest_path=manifest_path, secho=context.secho)
-        gws.run_foreach(("git", "push"), project_paths=projects, groups=groups, reverse=True)
+        gws = GitWS.from_path(manifest_path=manifest_path, group_filters=group_filters, secho=context.secho)
+        gws.run_foreach(("git", "push"), project_paths=projects, reverse=True)
 
 
 @main.command()
 @projects_option()
 @manifest_option()
-@groups_option()
+@group_filters_option()
 @pass_context
-def rebase(context, projects=None, manifest_path=None, groups=None):
+def rebase(context, projects=None, manifest_path=None, group_filters=None):
     """
     Run 'git rebase' on projects.
 
     This command behaves identical to `git ws foreach -- git rebase`.
     """
     with exceptionhandling(context):
-        gws = GitWS.from_path(manifest_path=manifest_path, secho=context.secho)
-        gws.run_foreach(("git", "rebase"), project_paths=projects, groups=groups)
+        gws = GitWS.from_path(manifest_path=manifest_path, group_filters=group_filters, secho=context.secho)
+        gws.run_foreach(("git", "rebase"), project_paths=projects)
 
 
 @main.command()
 @paths_argument()
 @click.option("--branch", "-b", is_flag=True, help="show branch information")
+@manifest_option()
+@group_filters_option()
 @pass_context
-def status(context, paths=None, branch: bool = False):
+def status(context, manifest_path=None, group_filters=None, paths=None, branch: bool = False):
     """
     Run 'git status' (displayed paths include the actual clone path).
     """
     with exceptionhandling(context):
-        gws = GitWS.from_path(secho=context.secho)
+        gws = GitWS.from_path(manifest_path=manifest_path, group_filters=group_filters, secho=context.secho)
         for status in gws.status(paths=process_paths(paths), branch=branch):
             text = str(status)
             if isinstance(status, FileStatus):
                 fgidx = "red" if status.work in (State.IGNORED, State.UNTRACKED) else "green"
                 parts = (
                     context.style(text[0], fg=fgidx),
-                    context.style(text[1], fg='red'),
+                    context.style(text[1], fg="red"),
                     text[2:],
                 )
                 click.echo("".join(parts))
@@ -272,13 +276,15 @@ def status(context, paths=None, branch: bool = False):
 @main.command()
 @paths_argument()
 @click.option("--stat", "stat", is_flag=True, help="show diffstat instead of patch.")
+@manifest_option()
+@group_filters_option()
 @pass_context
-def diff(context, paths=None, stat=False):
+def diff(context, manifest_path=None, group_filters=None, paths=None, stat=False):
     """
     Run 'git diff' on paths (displayed paths include the actual clone path).
     """
     with exceptionhandling(context):
-        gws = GitWS.from_path(secho=context.secho)
+        gws = GitWS.from_path(manifest_path=manifest_path, group_filters=group_filters, secho=context.secho)
         if stat:
             for diffstat in gws.diffstat(paths=process_paths(paths)):
                 click.echo(str(diffstat))
@@ -289,29 +295,33 @@ def diff(context, paths=None, stat=False):
 @main.command()
 @paths_argument()
 @force_option()
+@manifest_option()
+@group_filters_option()
 @pass_context
-def checkout(context, paths=None, force: bool = False):
+def checkout(context, manifest_path=None, group_filters=None, paths=None, force: bool = False):
     """
     Run 'git checkout' on paths and choose the right git clone and manifest revision automatically.
 
     Checkout all clones to their manifest revision, if no paths are given.
     """
     with exceptionhandling(context):
-        gws = GitWS.from_path(secho=context.secho)
+        gws = GitWS.from_path(manifest_path=manifest_path, group_filters=group_filters, secho=context.secho)
         gws.checkout(process_paths(paths), force=force)
 
 
 @main.command()
 @paths_argument()
-@pass_context
 @click.option("--force", "-f", "force", is_flag=True, help="allow adding otherwise ignored files")
 @click.option("--all", "-A", "all_", is_flag=True, help="add changes from all tracked and untracked files")
-def add(context, paths=None, force=False, all_=False):
+@manifest_option()
+@group_filters_option()
+@pass_context
+def add(context, manifest_path=None, group_filters=None, paths=None, force=False, all_=False):
     """
     Run 'git add' on paths and choose the right git clone automatically.
     """
     with exceptionhandling(context):
-        gws = GitWS.from_path(secho=context.secho)
+        gws = GitWS.from_path(manifest_path=manifest_path, group_filters=group_filters, secho=context.secho)
         gws.add(process_paths(paths), force=force, all_=all_)
 
 
@@ -321,25 +331,29 @@ def add(context, paths=None, force=False, all_=False):
 @force_option()
 @click.option("--cached", "cached", is_flag=True, help="only remove from index")
 @click.option("-r", "recursive", is_flag=True, help="allow recursive removal")
+@manifest_option()
+@group_filters_option()
 @pass_context
-def rm(context, paths=None, force=False, cached=False, recursive=False):
+def rm(context, manifest_path=None, group_filters=None, paths=None, force=False, cached=False, recursive=False):
     """
     Run 'git rm' on paths and choose the right git clone automatically.
     """
     with exceptionhandling(context):
-        gws = GitWS.from_path(secho=context.secho)
+        gws = GitWS.from_path(manifest_path=manifest_path, group_filters=group_filters, secho=context.secho)
         gws.rm(process_paths(paths), force=force, cached=cached, recursive=recursive)
 
 
 @main.command()
 @paths_argument()
+@manifest_option()
+@group_filters_option()
 @pass_context
-def reset(context, paths=None):
+def reset(context, manifest_path=None, group_filters=None, paths=None):
     """
     Run 'git reset' on paths and choose the right git clone automatically.
     """
     with exceptionhandling(context):
-        gws = GitWS.from_path(secho=context.secho)
+        gws = GitWS.from_path(manifest_path=manifest_path, group_filters=group_filters, secho=context.secho)
         gws.reset(process_paths(paths))
 
 
@@ -347,26 +361,28 @@ def reset(context, paths=None):
 @click.option("--message", "-m", help="commit message")
 @click.option("--all", "-a", "all_", is_flag=True, help="commit all changed files")
 @paths_argument()
+@manifest_option()
+@group_filters_option()
 @pass_context
-def commit(context, paths=None, message=None, all_=False):
+def commit(context, manifest_path=None, group_filters=None, paths=None, message=None, all_=False):
     """
     Run 'git commit' on paths and choose the right git clone automatically.
     """
     with exceptionhandling(context):
         if not message:
             raise ValueError("Please provide a commit message.")
-        gws = GitWS.from_path(secho=context.secho)
+        gws = GitWS.from_path(manifest_path=manifest_path, group_filters=group_filters, secho=context.secho)
         gws.commit(message, process_paths(paths), all_=all_)
 
 
 @main.command()
 @projects_option()
 @manifest_option()
-@groups_option()
+@group_filters_option()
 @reverse_option()
 @click.argument("command", nargs=-1, type=click.UNPROCESSED)
 @pass_context
-def foreach(context, command, projects=None, manifest_path=None, groups=None, reverse=False):
+def foreach(context, command, projects=None, manifest_path=None, group_filters=None, reverse=False):
     """
     Run 'command' on projects.
 
@@ -374,8 +390,8 @@ def foreach(context, command, projects=None, manifest_path=None, groups=None, re
     (i.e. `git ws foreach -- ls -l`)
     """
     with exceptionhandling(context):
-        gws = GitWS.from_path(manifest_path=manifest_path, secho=context.secho)
-        gws.run_foreach(command, project_paths=projects, groups=groups, reverse=reverse)
+        gws = GitWS.from_path(manifest_path=manifest_path, group_filters=group_filters, secho=context.secho)
+        gws.run_foreach(command, project_paths=projects, reverse=reverse)
 
 
 main.add_command(config)
diff --git a/gitws/_cli/info.py b/gitws/_cli/info.py
index 4ef427a..d505326 100644
--- a/gitws/_cli/info.py
+++ b/gitws/_cli/info.py
@@ -23,7 +23,7 @@
 
 from ..deptree import DepDotExporter
 from .common import exceptionhandling, pass_context
-from .options import groups_option, manifest_option
+from .options import group_filters_option, manifest_option
 
 
 @click.group()
@@ -34,49 +34,49 @@ def info():
 
 
 @info.command()
-@manifest_option()
 @pass_context
-def workspace_path(context, manifest_path=None):
+def workspace_path(context):
     """
     Print Path to Workspace.
     """
     with exceptionhandling(context):
-        gws = GitWS.from_path(manifest_path=manifest_path)
+        gws = GitWS.from_path()
         click.echo(str(gws.path))
 
 
 @info.command()
-@manifest_option()
 @pass_context
-def main_path(context, manifest_path=None):
+def main_path(context):
     """
     Print Path to Main Git Clone.
     """
     with exceptionhandling(context):
-        gws = GitWS.from_path(manifest_path=manifest_path)
+        gws = GitWS.from_path()
         click.echo(str(gws.workspace.main_path))
 
 
 @info.command()
 @manifest_option()
-@groups_option()
+@group_filters_option()
 @pass_context
-def project_paths(context, manifest_path=None, groups=None):
+def project_paths(context, manifest_path=None, group_filters=None):
     """
     Print Paths to all git clones.
     """
     with exceptionhandling(context):
-        gws = GitWS.from_path(manifest_path=manifest_path)
-        for project in gws.projects(filter_=gws.create_groups_filter(groups)):
+        gws = GitWS.from_path(manifest_path=manifest_path, group_filters=group_filters)
+        for project in gws.projects():
             project_path = gws.workspace.get_project_path(project)
             click.echo(project_path)
 
 
 @info.command()
 @manifest_option()
+@group_filters_option()
 @click.option("--dot", "-d", "dot", is_flag=True, help="Export DOT Format to be forwarded to graphviz.")
+@click.option("--primary", "-p", is_flag=True, help="Display primary dependencies only.")
 @pass_context
-def dep_tree(context, manifest_path=None, dot: bool = False):
+def dep_tree(context, manifest_path=None, group_filters=None, dot: bool = False, primary: bool = False):
     """
     Print Dependency Tree.
 
@@ -84,11 +84,13 @@ def dep_tree(context, manifest_path=None, dot: bool = False):
     Example:
 
     $ git ws info dep-tree --dot | dot -Tpng > dep-tree.png
+
+    $ git ws info dep-tree --dot | dot -Tsvg > dep-tree.svg
     """
 
     with exceptionhandling(context):
-        gws = GitWS.from_path(manifest_path=manifest_path)
-        deptree = gws.get_deptree()
+        gws = GitWS.from_path(manifest_path=manifest_path, group_filters=group_filters)
+        deptree = gws.get_deptree(primary=primary)
         if dot:
             click.echo("\n".join(DepDotExporter(deptree)))
         else:
diff --git a/gitws/_cli/manifest.py b/gitws/_cli/manifest.py
index 57df289..1b4ac71 100644
--- a/gitws/_cli/manifest.py
+++ b/gitws/_cli/manifest.py
@@ -22,7 +22,7 @@
 from gitws import GitWS, ManifestSpec
 
 from .common import COLOR_INFO, exceptionhandling, pass_context
-from .options import groups_option, manifest_option, output_option
+from .options import group_filters_option, manifest_option, output_option
 
 
 @click.group()
@@ -34,18 +34,18 @@ def manifest():
 
 @manifest.command()
 @manifest_option()
-@groups_option()
+@group_filters_option()
 @output_option()
 @pass_context
-def resolve(context, manifest_path=None, groups=None, output=None):
+def resolve(context, manifest_path=None, group_filters=None, output=None):
     """
     Print The Manifest With All Projects And All Their Dependencies.
 
     The output is a single manifest file with all dependencies and their dependencies.
     """
     with exceptionhandling(context):
-        gws = GitWS.from_path(manifest_path=manifest_path)
-        manifest = gws.get_manifest_spec(groups=groups, resolve=True)
+        gws = GitWS.from_path(manifest_path=manifest_path, group_filters=group_filters)
+        manifest = gws.get_manifest_spec(resolve=True)
         if output:
             manifest.save(Path(output))
         else:
@@ -54,10 +54,10 @@ def resolve(context, manifest_path=None, groups=None, output=None):
 
 @manifest.command()
 @manifest_option()
-@groups_option()
+@group_filters_option()
 @output_option()
 @pass_context
-def freeze(context, manifest_path=None, groups=None, output=None):
+def freeze(context, manifest_path=None, group_filters=None, output=None):
     """
     Print The Resolved Manifest With SHAs For All Project Revisions.
 
@@ -65,8 +65,8 @@ def freeze(context, manifest_path=None, groups=None, output=None):
     Revisions are replaced by the actual SHAs.
     """
     with exceptionhandling(context):
-        gws = GitWS.from_path(manifest_path=manifest_path)
-        manifest = gws.get_manifest_spec(groups=groups, freeze=True, resolve=True)
+        gws = GitWS.from_path(manifest_path=manifest_path, group_filters=group_filters)
+        manifest = gws.get_manifest_spec(freeze=True, resolve=True)
         if output:
             manifest.save(Path(output))
         else:
@@ -86,26 +86,28 @@ def validate(context, manifest_path=None):
 
 @manifest.command()
 @manifest_option()
+@group_filters_option()
 @pass_context
-def path(context, manifest_path=None):
+def path(context, manifest_path=None, group_filters=None):
     """
     Print Path to Main Manifest File.
     """
     with exceptionhandling(context):
-        gws = GitWS.from_path(manifest_path=manifest_path)
+        gws = GitWS.from_path(manifest_path=manifest_path, group_filters=group_filters)
         manifest = next(gws.manifests())
         click.echo(str(manifest.path))
 
 
 @manifest.command()
 @manifest_option()
+@group_filters_option()
 @pass_context
-def paths(context, manifest_path=None):
+def paths(context, manifest_path=None, group_filters=None):
     """
     Print Paths to ALL Manifest Files.
     """
     with exceptionhandling(context):
-        gws = GitWS.from_path(manifest_path=manifest_path)
+        gws = GitWS.from_path(manifest_path=manifest_path, group_filters=group_filters)
         for manifest in gws.manifests():
             click.echo(str(manifest.path))
 
diff --git a/gitws/_cli/options.py b/gitws/_cli/options.py
index 2bb89a9..6d8a326 100644
--- a/gitws/_cli/options.py
+++ b/gitws/_cli/options.py
@@ -41,7 +41,7 @@ def manifest_option(initial=False):
         help_ = f"Manifest file. '{MANIFEST_PATH_DEFAULT!s}' by default."
         default = MANIFEST_PATH_DEFAULT
     else:
-        help_ = "Manifest file. Initial clone/init settings by default."
+        help_ = "Manifest file. Initial/Configuration settings by default."
         default = None
     return click.option(
         "--manifest", "-M", "manifest_path", type=click.Path(dir_okay=False), default=default, help=help_
@@ -58,7 +58,7 @@ def force_option():
     return click.option("--force", "-f", is_flag=True, help="Enfore operation.")
 
 
-def groups_option(initial=False):
+def group_filters_option(initial=False):
     """Group Filter Option."""
     if initial:
         help_ = """\
@@ -72,7 +72,7 @@ def groups_option(initial=False):
 TODO: more details.
 Initial clone/init filter settings by default.
 """
-    return click.option("--groups", "-G", "groups", help=help_)
+    return click.option("--group-filter", "-G", "group_filters", metavar="FILTER", multiple=True, help=help_)
 
 
 def reverse_option():
diff --git a/gitws/_util.py b/gitws/_util.py
index 3d02079..6a1a422 100644
--- a/gitws/_util.py
+++ b/gitws/_util.py
@@ -44,7 +44,7 @@ def no_echo(text: str, **kwargs):
     """Just suppress `text`."""
 
 
-def resolve_relative(path: Path, base: Optional[Path] = None):
+def resolve_relative(path: Path, base: Optional[Path] = None) -> Path:
     """
     Return resolved `path` relative to `base`.
 
diff --git a/gitws/clone.py b/gitws/clone.py
index 39acfc8..4d2c8f6 100644
--- a/gitws/clone.py
+++ b/gitws/clone.py
@@ -20,6 +20,7 @@
 A :any:`Clone` is just the assembly of a :any:`Project` and its corresponding git interface (:any:`Git`).
 """
 
+import logging
 from pathlib import Path
 from typing import Generator, List, Optional, Tuple
 
@@ -28,6 +29,8 @@
 from .git import Git
 from .workspace import Workspace
 
+_LOGGER = logging.getLogger("git-ws")
+
 
 class Clone:
 
@@ -39,8 +42,6 @@ class Clone:
         git: Git.
     """
 
-    # pylint: disable=too-few-public-methods
-
     def __init__(self, project: Project, git: Git):
         self.project = project
         self.git = git
@@ -52,6 +53,31 @@ def from_project(workspace: Workspace, project: Project) -> "Clone":
         git = Git(project_path)
         return Clone(project, git)
 
+    def check(self, exists=True, diff=True, no_revision=True):
+        """
+        Check Clone.
+
+        Keyword Args:
+            exists: Check if cloned.
+            diff: Check if revisions differ
+            no_revision: No revision specification.
+        """
+        if exists:
+            self.git.check()
+        project = self.project
+        projectrev = project.revision
+        if projectrev:
+            if diff:
+                try:
+                    clonerev = self.git.get_revision()
+                except FileNotFoundError:
+                    clonerev = None
+                if clonerev and projectrev != clonerev:
+                    _LOGGER.warning("Clone %s is on different revision: %r", project.info, clonerev)
+        elif not project.is_main:
+            if no_revision:
+                _LOGGER.warning("Clone %s has no revision!", project.info)
+
     def __repr__(self):
         return get_repr(self, (self.project, self.git))
 
@@ -63,19 +89,16 @@ def info(self):
         project = self.project
         path = str(self.git.path)
         if project.is_main:
-            options = "MAIN"
+            options = f"MAIN {project.name!r}"
         else:
-            args = (project.name,) if project.name != path else tuple()
             options = get_repr(
-                args=args,
+                args=(project.name,),
                 kwargs=(
                     ("revision", project.revision, None),
-                    ("groups", ",".join(group.info for group in project.groups), ""),
+                    ("groups", ",".join(project.groups), ""),
                 ),
             )
-        if options:
-            return f"{path} ({options})"
-        return path
+        return f"{path} ({options})"
 
 
 ClonePaths = Tuple[Clone, Tuple[Path, ...]]
diff --git a/gitws/datamodel.py b/gitws/datamodel.py
index d951c61..336343f 100644
--- a/gitws/datamodel.py
+++ b/gitws/datamodel.py
@@ -17,21 +17,28 @@
 """
 Central :any:`GitWS` Datamodel.
 
-* :any:`AppConfigData`: :any:`GitWS` Configuration.
+* :any:`Group`: Dependency Group. A string.
+* :any:`Groups`: Tuple of Group instances.
+* :any:`GroupFilter`: Group Filter Specification. A string.
+* :any:`GroupFilters`: Tuple of GroupFilter instances.
+* :any:`GroupSelect`: Group Selection. A converted :any:`GroupFilter` as needed by :any:`GitWS`.
+* :any:`GroupSelects`: Tuple of GroupSelect instances.
+* :any:`Remote`: Remote Alias.
 * :any:`Defaults`: Default Values.
-* :any:`Group`: Dependency Group.
-* :any:`Manifest`: Manifest as needed by :any:`GitWS`.
-* :any:`ManifestSpec`: Specification of the actual project.
-* :any:`Project`: A Single Dependency as needed by :any:`GitWS`.
 * :any:`ProjectSpec`: Dependency Specification from Manifest File.
-* :any:`Remote`: Remote Alias.
+* :any:`Project`: A Single Dependency as needed by :any:`GitWS`.
+* :any:`ManifestSpec`: Specification of the actual project.
+* :any:`Manifest`: Manifest as needed by :any:`GitWS`.
+* :any:`AppConfigData`: :any:`GitWS` Configuration.
 """
 
+
+import re
 from pathlib import Path
 from typing import Any, Dict, Optional, Tuple
 
 import tomlkit
-from pydantic import BaseSettings, Extra, Field, root_validator
+from pydantic import BaseSettings, Extra, Field, root_validator, validator
 
 from ._basemodel import BaseModel
 from ._url import urljoin, urlsub
@@ -39,6 +46,180 @@
 from .const import MANIFEST_PATH_DEFAULT
 from .exceptions import ManifestError, ManifestNotFoundError
 
+_RE_GROUP = re.compile(r"\A[a-zA-Z0-9_][a-zA-Z0-9_\-]+\Z")
+
+ProjectPaths = Tuple[
+    str,
+]
+
+Group = str
+"""
+Dependency Group.
+
+A group is a name consisting of lower- and uppercase letters, numbers and underscore.
+Dashes are allowed. Except the first sign.
+
+Groups structure dependencies.
+"""
+
+
+def validate_group(group):
+    """
+    Validate Group.
+
+    Group is just a `str` for performance reasons. This function does the validation.
+    """
+    mat = _RE_GROUP.match(group)
+    if not mat:
+        raise ValueError(f"Invalid group {group!r}")
+
+
+class Groups(tuple):
+
+    """Groups."""
+
+    @staticmethod
+    def validate(groups):
+        """
+        Validate Groups.
+
+        Groups are just a `tuple` of `str` for performance reasons. This function does the validation.
+        """
+        for group in groups:
+            validate_group(group)
+
+
+_RE_GROUP_FILTER = re.compile(r"\A(?P<select>[\-\+])(?P<group>[a-zA-Z0-9_][a-zA-Z0-9_\-]*)?(@(?P<path>.+))?\Z")
+
+GroupFilter = str
+"""
+Group Filter.
+
+A group filter is a group name prefixed by '+' or '-', to select or deselect the group.
+A group filter can have an optional path at the end.
+
+Any :any:`GroupFilter` is later-on converted to a :any:`GroupSelect`.
+"""
+
+
+def validate_group_filter(group_filter):
+    """
+    Groups Filter.
+
+    Group Filters are just a `tuple` of `str` for performance reasons. This function does the validation.
+    """
+    mat = _RE_GROUP_FILTER.match(group_filter)
+    if not mat:
+        raise ValueError(f"Invalid group filter {group_filter!r}")
+
+
+class GroupFilters(tuple):
+    """
+    Groups Filter Specification from User.
+
+    Used by Config and Command Line Interface.
+    """
+
+    @staticmethod
+    def validate(group_filters):
+        """
+        Check Groups Filter.
+
+        Group Filters are just a `tuple` of `str` for performance reasons. This function does the validation.
+        """
+        for group_filter in group_filters:
+            validate_group_filter(group_filter)
+
+
+class GroupSelect(BaseModel):
+
+    """
+    Group Selection.
+
+    A group selection selects/deselects a specific group for a specific path.
+
+    Keyword Args:
+        select: Select (`True`) or Deselect (`False`)
+        group: Group Name.
+        path: Path.
+    """
+
+    group: Optional[Group] = None
+    """Group."""
+    select: bool
+    """Selected or not."""
+    path: Optional[str] = None
+    """Path."""
+
+    @staticmethod
+    def from_group_filter(group_filter) -> "GroupSelect":
+        """
+        Create Group Selection from `group_filter`.
+
+        >>> GroupSelect.from_group_filter("+test")
+        GroupSelect(group='test', select=True)
+        >>> GroupSelect.from_group_filter("-test")
+        GroupSelect(group='test', select=False)
+        >>> GroupSelect.from_group_filter("-test@path")
+        GroupSelect(group='test', select=False, path='path')
+        >>> GroupSelect.from_group_filter("te-st")
+        Traceback (most recent call last):
+            ...
+        ValueError: Invalid group selection 'te-st'
+        """
+        mat = _RE_GROUP_FILTER.match(group_filter)
+        if not mat:
+            raise ValueError(f"Invalid group selection {group_filter!r}")
+        data = mat.groupdict()
+        data["select"] = data["select"] == "+"
+        return GroupSelect(**data)
+
+    def __str__(self):
+        select = "+" if self.select else "-"
+        path = f"@{self.path}" if self.path else ""
+        return f"{select}{self.group}{path}"
+
+
+class GroupSelects(tuple):
+
+    """Collection from :any:`GroupSelect`."""
+
+    @staticmethod
+    def from_group_filters(group_filters: Optional[GroupFilters] = None) -> "GroupSelects":
+        """
+        Creage :any:`GroupSelects` from `group_filters`.
+
+        >>> GroupSelects.from_group_filters()
+        ()
+        >>> GroupSelects.from_group_filters(('', ))
+        ()
+        >>> GroupSelects.from_group_filters(('+test', ))
+        (GroupSelect(group='test', select=True),)
+        >>> GroupSelects.from_group_filters(('+test', '-doc'))
+        (GroupSelect(group='test', select=True), GroupSelect(group='doc', select=False))
+        """
+        group_filters = group_filters or GroupFilters()
+        items = [item.strip() for item in group_filters]
+        return GroupSelects(GroupSelect.from_group_filter(item) for item in items if item)
+
+    @staticmethod
+    def from_groups(groups: Optional[Groups] = None) -> "GroupSelects":
+        """
+        Creage :any:`GroupSelects` from `group_filters`.
+
+        >>> GroupSelects.from_groups()
+        ()
+        >>> GroupSelects.from_groups(('', ))
+        ()
+        >>> GroupSelects.from_groups(('test', ))
+        (GroupSelect(group='test', select=True),)
+        >>> GroupSelects.from_groups(('test', 'doc'))
+        (GroupSelect(group='test', select=True), GroupSelect(group='doc', select=True))
+        """
+        groups = groups or Groups()
+        items = [item.strip() for item in groups]
+        return GroupSelects(GroupSelect(group=item, select=True) for item in items if item)
+
 
 class Remote(BaseModel, allow_population_by_field_name=True):
     """
@@ -65,8 +246,10 @@ class Defaults(BaseModel):
     These default values are used, if the project does not specify them.
 
     Keyword Args:
-        remote: Remote Name
-        revision: Revision
+        remote: Remote Name.
+        revision: Revision. Tag or Branch. SHA does not make sense here.
+        groups: Dependency Groups.
+        with_groups: Group Selection for refered projects.
     """
 
     remote: Optional[str] = None
@@ -75,42 +258,11 @@ class Defaults(BaseModel):
     revision: Optional[str] = None
     """The revision if not specified by the dependency. Tag or Branch. SHA does not make sense here."""
 
+    groups: Optional[Groups] = Groups()
+    """The `groups` if not specified by the dependency."""
 
-class Group(BaseModel):
-    """
-    Dependency Group.
-
-    Dependency groups structure dependencies.
-    Groups are optional by default.
-
-    Args:
-        name: Name
-
-    Keyword Args:
-        optional: Group is optional. Default is `True`.
-    """
-
-    name: str
-    """Group Name."""
-
-    optional: bool = True
-    """Group is optional and not required. Default is `True`."""
-
-    @property
-    def info(self):
-        """
-        `repr`-like information string.
-
-        >>> Group(name='name').info
-        'name'
-        >>> Group(name='name', optional=False).info
-        '+name'
-        >>> Group(name='name', optional=True).info
-        'name'
-        """
-        if not self.optional:
-            return f"+{self.name}"
-        return self.name
+    with_groups: Optional[Groups] = Groups()
+    """The `with_groups` if not specified by the dependency."""
 
 
 class Project(BaseModel):
@@ -125,10 +277,11 @@ class Project(BaseModel):
         path: Project Filesystem Path. Relative to Workspace Root Directory.
 
     Keyword Args:
-        url: URL
-        revision: Revision
+        url: URL. Assembled from `remote`s `url_base`, `sub_url` and/or `name`.
+        revision: Revision to be checked out. Tag, branch or SHA.
         manifest_path: Path to manifest. Relative to ProjectSpec Filesystem Path. `git-ws.toml` by default.
         groups: Dependency Groups.
+        with_groups: Group Selection for refered project.
         is_main: Project is Main Project.
 
     The :any:`ProjectSpec` represents the User Interface. The options which can be specified in the manifest file.
@@ -149,17 +302,32 @@ class Project(BaseModel):
     """URL. Assembled from `remote`s `url_base`, `sub_url` and/or `name`."""
 
     revision: Optional[str] = None
-    """Revision to be checked out."""
+    """Revision to be checked out. Tag, branch or SHA."""
 
     manifest_path: str = str(MANIFEST_PATH_DEFAULT)
     """Path to the manifest file. Relative to `path`."""
 
-    groups: Tuple[Group, ...] = tuple()
-    """Groups."""
+    groups: Groups = Groups()
+    """Dependency Groups."""
+
+    with_groups: Groups = Field(Groups(), alias="with-groups")
+    """Group Selection for refered project."""
 
     is_main: bool = False
     """Project is the main project."""
 
+    @validator("groups", allow_reuse=True)
+    def _groups(cls, values):
+        # pylint: disable=no-self-argument,no-self-use
+        Groups.validate(values)
+        return values
+
+    @validator("with_groups", allow_reuse=True)
+    def _with_groups(cls, values):
+        # pylint: disable=no-self-argument,no-self-use
+        Groups.validate(values)
+        return values
+
     @property
     def info(self):
         """
@@ -171,14 +339,14 @@ def info(self):
         "name (path='path')"
         >>> Project(name='name', path='name', revision='main').info
         "name (revision='main')"
-        >>> Project(name='name', path='name', groups=(Group(name='test'), Group(name='doc', optional=False))).info
-        "name (groups='test,+doc')"
+        >>> Project(name='name', path='name', groups=('test', 'doc')).info
+        "name (groups='test,doc')"
         """
         options = get_repr(
             kwargs=(
                 ("revision", self.revision, None),
                 ("path", str(self.path), self.name),
-                ("groups", ",".join(group.info for group in self.groups), ""),
+                ("groups", ",".join(self.groups), ""),
             )
         )
         if self.is_main:
@@ -193,17 +361,19 @@ def from_spec(manifest_spec: "ManifestSpec", spec: "ProjectSpec", refurl: Option
         Create :any:`Project` from `manifest_spec` and `spec`.
 
         Args:
-            manifest_spec:
-            defaults: Default settings if not given by `spec`.
-            remotes: Remotes
+            manifest_spec: Manifest Specification.
             spec: Base project to be resolved.
 
+        Keyword Args:
+            refurl: Remote URL of the `manifest_spec`. If specified, relative URLs are resolved.
+
         :any:`Project.from_spec()` resolves a :any:`ProjectSpec` into a :any:`Project`.
         :any:`ProjectSpec.from_project()` does the reverse.
         """
         defaults = manifest_spec.defaults
         remotes = manifest_spec.remotes
-        groups = manifest_spec.groups
+        project_groups = spec.groups or defaults.groups
+        project_with_groups = spec.with_groups or defaults.with_groups
         url = spec.url
         if not url:
             # URL assembly
@@ -221,14 +391,14 @@ def from_spec(manifest_spec: "ManifestSpec", spec: "ProjectSpec", refurl: Option
 
         # Resolve relative URLs.
         url = urljoin(refurl, url)
-        groupdict = {group.name: group for group in groups} if spec.groups else {}
         return Project(
             name=spec.name,
             path=spec.path or spec.name,
             url=url,
             revision=spec.revision or defaults.revision,
             manifest_path=spec.manifest_path,
-            groups=[groupdict.get(name, Group(name=name)) for name in spec.groups],
+            groups=project_groups,
+            with_groups=project_with_groups,
         )
 
 
@@ -249,6 +419,7 @@ class ProjectSpec(BaseModel, allow_population_by_field_name=True):
         path: Project Filesystem Path. Relative to Workspace Root Directory.
         manifest_path: Path to manifest. Relative to ProjectSpec Filesystem Path. `git-ws.toml` by default.
         groups: Dependency Groups.
+        with_groups: Group Selection for refered project.
 
     Some parameters are restricted:
 
@@ -285,8 +456,11 @@ class ProjectSpec(BaseModel, allow_population_by_field_name=True):
     manifest_path: str = str(MANIFEST_PATH_DEFAULT)
     """Path to the manifest file. Relative to `path`."""
 
-    groups: Tuple[str, ...] = tuple()
-    """Group Names."""
+    groups: Groups = Groups()
+    """Dependency Groups."""
+
+    with_groups: Groups = Field(Groups(), alias="with-groups")
+    """Group Selection for refered project."""
 
     @root_validator(allow_reuse=True)
     def _remote_or_url(cls, values):
@@ -302,6 +476,18 @@ def _remote_or_url(cls, values):
             raise ValueError("'sub-url' requires 'remote'")
         return values
 
+    @validator("groups", allow_reuse=True)
+    def _groups(cls, values):
+        # pylint: disable=no-self-argument,no-self-use
+        Groups.validate(values)
+        return values
+
+    @validator("with_groups", allow_reuse=True)
+    def _with_groups(cls, values):
+        # pylint: disable=no-self-argument,no-self-use
+        Groups.validate(values)
+        return values
+
     @staticmethod
     def from_project(project: Project) -> "ProjectSpec":
         """
@@ -321,11 +507,12 @@ def from_project(project: Project) -> "ProjectSpec":
             url=project.url,
             revision=project.revision,
             manifest_path=project.manifest_path,
-            groups=[group.name for group in project.groups],
+            groups=project.groups,
+            with_groups=project.with_groups,
         )
 
 
-class Manifest(BaseModel, extra=Extra.allow):
+class Manifest(BaseModel, extra=Extra.allow, allow_population_by_field_name=True):
 
     """
     Manifest.
@@ -333,11 +520,10 @@ class Manifest(BaseModel, extra=Extra.allow):
     A manifest describes the actual project and its dependencies.
 
     Keyword Args:
-        groups: Groups.
+        group_filters. Group Filtering.
         dependencies: Dependency Projects.
         path: Filesystem Path. Relative to Workspace Root Directory.
 
-
     The :any:`ManifestSpec` represents the User Interface. The options which can be specified in the manifest file.
     The :any:`Manifest` is the resolved version of :any:`ManifestSpec` with all calculated information needed by
     :any:`GitWS` to operate.
@@ -345,8 +531,8 @@ class Manifest(BaseModel, extra=Extra.allow):
     :any:`Manifest.from_spec()` resolves a :any:`ManifestSpec` into a :any:`Manifest`.
     """
 
-    groups: Tuple[Group, ...] = tuple()
-    """Groups."""
+    group_filters: GroupFilters = Field(GroupFilters(), alias="group-filters")
+    """Group Filtering."""
 
     dependencies: Tuple[Project, ...] = tuple()
     """Dependencies."""
@@ -354,6 +540,12 @@ class Manifest(BaseModel, extra=Extra.allow):
     path: Optional[str] = None
     """Path to the manifest file, relative to project path."""
 
+    @validator("group_filters", allow_reuse=True)
+    def _group_filters(cls, values):
+        # pylint: disable=no-self-argument,no-self-use
+        GroupFilters.validate(values)
+        return values
+
     @staticmethod
     def from_spec(spec: "ManifestSpec", path: Optional[str] = None, refurl: Optional[str] = None) -> "Manifest":
         """
@@ -367,11 +559,11 @@ def from_spec(spec: "ManifestSpec", path: Optional[str] = None, refurl: Optional
             refurl: URL of the repository containing `spec`.
 
         If `refurl` is specified, any relative URL in the :any:`ManifestSpec` and referred :any:`ProjectSpec` s
-        is resolved to an absolute URL.
+        are resolved to a absolute URLs.
         """
         dependencies = [Project.from_spec(spec, project_spec, refurl=refurl) for project_spec in spec.dependencies]
         return Manifest(
-            groups=spec.groups,
+            group_filters=spec.group_filters,
             dependencies=dependencies,
             path=path,
         )
@@ -391,7 +583,7 @@ class ManifestSpec(BaseModel, allow_population_by_field_name=True):
     Keyword Args:
         version: Version String. Actually 1.0.
         remotes: Remote Aliases.
-        groups: Groups.
+        group_filters. Group Filtering.
         defaults: Default settings.
         dependencies: Dependency Projects.
     """
@@ -406,8 +598,8 @@ class ManifestSpec(BaseModel, allow_population_by_field_name=True):
     remotes: Tuple[Remote, ...] = tuple()
     """Remotes."""
 
-    groups: Tuple[Group, ...] = tuple()
-    """Groups."""
+    group_filters: GroupFilters = Field(GroupFilters(), alias="group-filters")
+    """Group Filtering."""
 
     defaults: Defaults = Defaults()
     """Default Values."""
@@ -427,16 +619,10 @@ def _remotes_unique(cls, values):
                 raise ValueError(f"Remote name {name!r} is used more than once")
         return values
 
-    @root_validator(allow_reuse=True)
-    def _groups_unique(cls, values):
+    @validator("group_filters", allow_reuse=True)
+    def _group_filters(cls, values):
         # pylint: disable=no-self-argument,no-self-use
-        names = set()
-        for group in values.get("groups", None) or []:
-            name = group.name
-            if name not in names:
-                names.add(name)
-            else:
-                raise ValueError(f"Group name {name!r} is used more than once")
+        GroupFilters.validate(values)
         return values
 
     @classmethod
@@ -444,8 +630,7 @@ def load(cls, path: Path) -> "ManifestSpec":
         """
         Load :any:`ManifestSpec` from `path`.
 
-        The file referenced by `path` should be a TOML file according to the
-        manifest scheme.
+        The file referenced by `path` must be a TOML file according to the manifest scheme.
 
         Raises:
             ManifestNotFoundError: if file is not found
@@ -467,8 +652,7 @@ def dump(self, doc: Optional[tomlkit.TOMLDocument] = None, path: Optional[Path]
         Return :any:`ManifestSpec` as string.
 
         The output will include an inline documentation of all available options.
-        If `doc` or `path` are specified, any additional attributes and comments added by the user before
-        are **kept**.
+        If `doc` or `path` are specified, any additional attributes and comments are **kept**.
 
         Args:
             doc: Existing document to be updated.
@@ -502,19 +686,20 @@ def save(self, path: Path, update=True):
 
     @classmethod
     def upgrade(cls, path: Path):
-        """Upgrade :any:`ManifestSpec` at `path` to latest revision including documentation."""
+        """Upgrade :any:`ManifestSpec` at `path` to latest version including documentation."""
         # read
         content = path.read_text()
         try:
             olddoc = tomlkit.parse(content)
-            data = dict(olddoc)
-            obj = cls(**data)
+            olddata = dict(olddoc)
+            olddata.pop("groups", None)
+            obj = cls(**olddata)
         except Exception as exc:
             raise ManifestError(resolve_relative(path), str(exc)) from None
 
         # merge
         newdoc = cls._create()
-        for key, value in olddoc.items():
+        for key, value in olddata.items():
             newdoc[key] = value
         for key, value in as_dict(obj).items():
             newdoc[key] = value
@@ -532,175 +717,39 @@ def _create() -> tomlkit.TOMLDocument:
         add_info(
             doc,
             """
-Welcome to Git Workspace's Manifest. It actually contains 4 parts:
+Git Workspace's Manifest. Please see the documentation at:
 
-* Remotes
-* Groups
-* Defaults
-* Dependencies
+https://git-ws.readthedocs.io/en/latest/manual/manifest.html
 """,
         )
+        doc.add(tomlkit.nl())
+        doc.add(tomlkit.nl())
 
-        # Remotes
-        add_info(
-            doc,
-            """\
-=========
- Remotes
-=========
-
-Remotes just refer to a directory with repositories.
-
-We support relative paths for dependencies. So, if your dependencies are next
-to your repository, you might NOT need any remote.
-In other terms: You only need remotes if your dependencies are located on
-OTHER servers than your server with this manifest.
-
-Remotes have two attributes:
-* name: Required. String.
-        Name of the remote. Any valid string. Must be unique within your
-        manifest.
-* url-base: Required. String.
-            URL Prefix. The project 'name' or 'sub-url' will be appended
-            later-on.
-""",
-        )
-        example = ManifestSpec(remotes=[Remote(name="myremote", url_base="https://github.com/myuser")])
+        # Group Filtering
+        example = ManifestSpec(group_filters=GroupFilters(("+test", "-doc", "+feature@path")))
         add_comment(doc, example.dump(doc=tomlkit.document())[:-1])
-        doc.add("remotes", tomlkit.aot())
+        doc.add("group-filters", tomlkit.array())
         doc.add(tomlkit.nl())
         doc.add(tomlkit.nl())
 
-        # Groups
-        add_info(
-            doc,
-            """\
-=========
- Groups
-=========
-
-Groups structure dependencies.
-
-Groups are optional by default.
-If a dependency belongs to a group, it becomes optional likewise.
-Groups can be later on selected/deselected by '+group' or '-group'.
-An optional group can be selected by '+group',
-a non-optional group can be deselected by '-group'.
-Deselection has higher priority than selection.
-
-Dependencies can refer to non-existing groups. You do NOT need to specify
-all used groups.
-
-Groups have two attributes:
-* name: Required. String.
-        Name of the group. Any valid string. Must be unique within your
-        manifest.
-* optional: Optional. Bool. Default is True.
-            Specifies if the group is optional. Meaning it must be selected
-            explicitly. Otherwise the dependency is not added by default.
-
-The following lines set a group as non-optional.""",
-        )
-        example = ManifestSpec(groups=[Group(name="test", optional=False)])
+        # Remotes
+        example = ManifestSpec(remotes=[Remote(name="myremote", url_base="https://github.com/myuser")])
         add_comment(doc, example.dump(doc=tomlkit.document())[:-1])
-        doc.add("groups", tomlkit.aot())
+        doc.add("remotes", tomlkit.aot())
         doc.add(tomlkit.nl())
         doc.add(tomlkit.nl())
 
         # Defaults
-        add_info(
-            doc,
-            """\
-==========
- Defaults
-==========
-
-The 'defaults' section specifies default values for dependencies.
-
-* remote: Optional. String.
-          Remote used as default.
-          The 'remote' MUST be defined in the 'remotes' section above!
-* revision: Optional. String.
-            Revision used as default. Tag or Branch.
-
-NOTE: It is recommended to specify a default revision (i.e. 'main').
-      If a dependency misses 'revision', GitWS will not take care about
-      revision handling. This may lead to strange side-effects. You
-      have been warned.""",
-        )
-
         doc.add("defaults", as_dict(Defaults()))
-        example = ManifestSpec(defaults=Defaults(remote="myserver", revision="main"))
+        example = ManifestSpec(
+            defaults=Defaults(remote="myserver", revision="main", groups=("+test",), with_groups=("doc",))
+        )
         add_comment(doc, "\n".join(example.dump(doc=tomlkit.document()).split("\n")[1:-1]))
         doc.add(tomlkit.nl())
         doc.add(tomlkit.nl())
 
         # Dependencies
-        add_info(
-            doc,
-            """\
-==============
- Dependencies
-==============
-
-The 'dependencies' section specifies all your git clones you need for your
-project to operate.
-
-A dependency has the following attributes:
-* name: Required. String.
-        Just name your dependency. It is recommended to choose a
-        unique name, but not a must.
-* remote: Optional. String. Restricted (see RESTRICTIONS below).
-          Remote Alias.
-          The 'remote' MUST be defined in the 'remotes' section above!
-          The 'remote' can also be specified in the 'defaults' section.
-* sub-url: Optional. String. Default: '../{name}[.git]' (see NOTE1 below).
-           Relative URL to 'url-base' of your specified 'remote'
-           OR
-           Relative URL to the URL of the repository containing this
-           manifest.
-* url: Optional. String. Restricted (see RESTRICTIONS below).
-       Absolute URL to the dependent repository.
-* revision: Optional. String.
-            Revision to be checked out.
-            If this attribute is left blank, GitWS does NOT manage the
-            dependency revision (see NOTE2 below)!
-            The 'revision' can also be specified in the 'defaults' section.
-* path: Optional. String. Default is '{name}'.
-        Project Filesystem Path. Relative to Workspace Root Directory.
-        The dependency 'name' is used as default for 'path'.
-        The 'path' MUST be unique within your manifest.
-* manifest_path: Optional. String. Default: 'git-ws.toml'.
-                  Path to manifest.
-                  Relative to 'path'.
-                  Avoid changing it! It is just additional effort.
-* groups: Optional. List of Strings.
-          Dependency Groups.
-          Dependencies can be categorized into groups.
-          Groups are optional by default. See 'groups' section above.
-
-NOTE1: 'sub-url' is '../{name}[.git]' by default. Meaning if the dependency
-       is next to your repository containing this manifest, the dependency
-       is automatically found.
-       The '.git' suffix is appended if the repository containing this
-       manifest uses a '.git' suffix.
-
-NOTE2: It is recommended to specify a revision (i.e. 'main') either
-       explicitly or via the 'default' section.
-       Without a 'revision' GitWS will not take care about revision
-       handling. This may lead to strange side-effects.
-       You have been warned.
-
-RESTRICTIONS:
-
-* `remote` and `url` are mutually exclusive.
-* `url` and `sub-url` are likewise mutually exclusive
-* `sub-url` requires a `remote`.
-
-
-A full flavored dependency using a 'remote':""",
-        )
-
+        add_info(doc, "A full flavored dependency using a 'remote':")
         example = ManifestSpec(
             dependencies=[
                 ProjectSpec(
@@ -771,13 +820,13 @@ class AppConfigData(BaseSettings, extra=Extra.allow):
     This option can be overridden by specifying the `GIT_WS_COLOR_UI` environment variable.
     """
 
-    groups: Optional[str] = Field(description="The groups to operate on.")
+    group_filters: Optional[GroupFilters] = Field(description="The groups to operate on.")
     """
     The groups to operate on.
 
     This is a filter for groups to operate on during workspace actions.
 
-    This option can be overridden by specifying the `GIT_WS_GROUPS` environment variable.
+    This option can be overridden by specifying the `GIT_WS_GROUP_FILTERS` environment variable.
     """
 
     @staticmethod
diff --git a/gitws/deptree.py b/gitws/deptree.py
index acc38df..7d95ffe 100644
--- a/gitws/deptree.py
+++ b/gitws/deptree.py
@@ -1,3 +1,19 @@
+# Copyright 2022 c0fec0de
+#
+# This file is part of Git Workspace.
+#
+# Git Workspace is free software: you can redistribute it and/or modify it under
+# the terms of the GNU Lesser General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# Git Workspace is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with Git Workspace. If not, see <https://www.gnu.org/licenses/>.
+
 """Dependency Tree."""
 
 import logging
@@ -13,7 +29,7 @@
 _LOGGER = logging.getLogger("git-ws")
 
 
-class Node(NodeMixin):
+class DepNode(NodeMixin):
     """
     Dependency Tree Node.
     """
@@ -24,17 +40,19 @@ def __init__(self, project, is_primary=False, parent=None):
         self.parent = parent
 
 
-def get_deptree(workspace: Workspace, manifest: Manifest) -> Node:
+def get_deptree(workspace: Workspace, manifest: Manifest, primary: bool = False) -> DepNode:
     """Calculate Dependency Tree."""
     main = str(workspace.info.main_path)
-    main_node = Node(Project(name=main, path=main), is_primary=True)
+    main_node = DepNode(Project(name=main, path=main), is_primary=True)
     primaries: List[str] = []
     edges: List[Tuple[str, str]] = []
-    _build(primaries, edges, workspace, main_node, manifest)
+    _build(primaries, edges, workspace, main_node, manifest, primary=primary)
     return main_node
 
 
-def _build(primaries: List, edges: List, workspace: Workspace, node: Node, manifest: Manifest):
+def _build(
+    primaries: List, edges: List, workspace: Workspace, node: DepNode, manifest: Manifest, primary: bool = False
+):
     _LOGGER.debug("get_deptree(primaries=%r, path=%r)", primaries, node.project.path)
     for project in manifest.dependencies:
         edge = (node.project.path, project.path)
@@ -44,7 +62,9 @@ def _build(primaries: List, edges: List, workspace: Workspace, node: Node, manif
         is_primary = project.path not in primaries
         if is_primary:
             primaries.append(project.path)
-        project_node = Node(project, is_primary=is_primary, parent=node)
+        elif primary:
+            continue
+        project_node = DepNode(project, is_primary=is_primary, parent=node)
         manifest_path = workspace.get_project_path(project) / project.manifest_path
         try:
             manifest_spec = ManifestSpec.load(manifest_path)
@@ -52,7 +72,7 @@ def _build(primaries: List, edges: List, workspace: Workspace, node: Node, manif
             continue
         manifest = Manifest.from_spec(manifest_spec)
         if is_primary:
-            _build(primaries, edges, workspace, project_node, manifest)
+            _build(primaries, edges, workspace, project_node, manifest, primary=primary)
 
 
 class DepDotExporter(DotExporter):
@@ -74,7 +94,7 @@ def _edgeattr(node, child):
         labels = []
         if project.revision:
             labels.append(project.revision)
-        groups = ",".join(group.info for group in project.groups)
+        groups = ",".join(project.groups)
         if project.groups:
             labels.append(f"({groups})")
         if labels:
@@ -84,5 +104,5 @@ def _edgeattr(node, child):
             return ";".join(attrs)
         return None
 
-    def __init__(self, node: Node):
+    def __init__(self, node: DepNode):
         super().__init__(node, nodenamefunc=self._nodename, edgeattrfunc=self._edgeattr)
diff --git a/gitws/filters.py b/gitws/filters.py
deleted file mode 100644
index e9d6c29..0000000
--- a/gitws/filters.py
+++ /dev/null
@@ -1,142 +0,0 @@
-# Copyright 2022 c0fec0de
-#
-# This file is part of Git Workspace.
-#
-# Git Workspace is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Lesser General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# Git Workspace is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public License along
-# with Git Workspace. If not, see <https://www.gnu.org/licenses/>.
-
-"""Filters."""
-from typing import Any, Optional, Tuple
-
-from ._basemodel import BaseModel
-
-
-def default_filter(item: Any) -> bool:
-    """Default Filter - always returning True."""
-    # pylint: disable=unused-argument
-    return True
-
-
-class Filter(BaseModel):
-
-    """
-    A Generic Filter With A String Interface.
-
-    Keyword Args:
-        only: Limit to these only. Highest precedence.
-        without: Deselected.
-        with_: Selected optionals. Lowest precedence.
-
-    A simple example:
-
-    >>> filter_ = Filter.from_str("+doc,-test")
-    >>> filter_
-    Filter(without=('test',), with_=('doc',))
-    >>> filter_(('other', ))
-    True
-    >>> filter_(('doc', ))
-    True
-    >>> filter_(('test', ))
-    False
-
-    The following table describes the full behaviour:
-
-    >>> from tabulate import tabulate
-    >>> specs = ("", "test", "+test", "-test", "+doc,-test", "-doc,+test")
-    >>> values = (tuple(), ('test',), ('test', 'doc'), ('lint', 'doc'))
-    >>> filters = [Filter.from_str(spec) for spec in specs]
-    >>> disabled = None
-    >>> overview = [[value] + [filter_(value, disabled=disabled) for filter_ in filters] for value in values]
-    >>> print(tabulate(overview, headers = ["value / spec",] + [f"{spec!r}" for spec in specs]))
-    value / spec     ''    'test'    '+test'    '-test'    '+doc,-test'    '-doc,+test'
-    ---------------  ----  --------  ---------  ---------  --------------  --------------
-    ()               True  False     True       True       True            True
-    ('test',)        True  True      True       False      False           True
-    ('test', 'doc')  True  True      True       False      False           False
-    ('lint', 'doc')  True  False     True       True       True            False
-
-    The `disabled` option defines the default behaviour, which is overwritten by `with`:
-
-    >>> filter_ = Filter()
-    >>> filter_(('test', ), disabled=('test',))
-    False
-    >>> filter_ = Filter(with_=('test',))
-    >>> filter_(('test', ), disabled=('test',))
-    True
-
-    The following table describes the full behaviour with `disabled=('test', )`:
-
-    >>> disabled = ('test', )
-    >>> overview = [[value] + [filter_(value, disabled=disabled) for filter_ in filters] for value in values]
-    >>> print(tabulate(overview, headers = ["value / spec",] + [f"{spec!r}" for spec in specs]))
-    value / spec     ''     'test'    '+test'    '-test'    '+doc,-test'    '-doc,+test'
-    ---------------  -----  --------  ---------  ---------  --------------  --------------
-    ()               True   False     True       True       True            True
-    ('test',)        False  True      True       False      False           True
-    ('test', 'doc')  False  True      True       False      False           False
-    ('lint', 'doc')  True   False     True       True       True            False
-    """
-
-    only: Tuple[str, ...] = tuple()
-    """Only select items matching. Highest Precedence."""
-
-    without: Tuple[str, ...] = tuple()
-    """Deselect items matching. Mid Precedence."""
-
-    with_: Tuple[str, ...] = tuple()
-    """Include optional items matching. Lowest Precedence."""
-
-    @staticmethod
-    def from_str(expr: str) -> "Filter":
-        """
-        Create :any:`Filter` from `expr`.
-
-        >>> Filter.from_str("")
-        Filter()
-        >>> Filter.from_str("+test")
-        Filter(with_=('test',))
-        >>> Filter.from_str("-test,-doc, +lint, imp")
-        Filter(only=('imp',), without=('test', 'doc'), with_=('lint',))
-        >>> Filter.from_str("+lint, -doc")
-        Filter(without=('doc',), with_=('lint',))
-        """
-        parts = tuple(part.strip() for part in expr.split(",") if part.strip())
-        only = tuple(part for part in parts if not part.startswith(("+", "-")))
-        with_ = tuple(part[1:] for part in parts if part.startswith("+"))
-        without = tuple(part[1:] for part in parts if part.startswith("-"))
-        return Filter(only=only, with_=with_, without=without)
-
-    def __call__(self, items: Tuple[str, ...], disabled: Optional[Tuple[str, ...]] = None) -> bool:
-        itemsset = set(items)
-
-        # ONLY
-        onlyset = set(self.only)
-        if onlyset:
-            return bool(onlyset & itemsset)
-
-        # Trivia
-        if not itemsset:
-            return True
-
-        # WITHOUT
-        withoutset = set(self.without)
-        if itemsset & withoutset:
-            return False
-
-        # WITH
-        disableditems = set(disabled or tuple()) & itemsset
-        withset = set(self.with_)
-        if disableditems & withset:
-            return True
-
-        # DISABLE
-        return not bool(disableditems)
diff --git a/gitws/git.py b/gitws/git.py
index aeb5580..a818789 100644
--- a/gitws/git.py
+++ b/gitws/git.py
@@ -24,7 +24,7 @@
 
 from ._basemodel import BaseModel
 from ._util import get_repr, run
-from .exceptions import NoGitError
+from .exceptions import GitCloneMissingError, NoGitError
 
 _RE_STATUS = re.compile(r"\A(?P<index>.)(?P<work>.)\s((?P<orig_path>.+) -> )?(?P<path>.+)\Z")
 _RE_DIFFSTAT = re.compile(r"\A\s(?P<path>.+)\s\|\s(?P<stat>.+)\Z")
@@ -33,7 +33,7 @@
 
 class State(Enum):
     """
-    Actual State
+    Actual State (Part of `git status` line).
 
     >>> State(" ")
     <State.UNMODIFIED: ' '>
@@ -57,7 +57,7 @@ def __str__(self):
 
 
 class Status(BaseModel):
-    """Status."""
+    """Status (One `git status` line."""
 
     def with_path(self, path: Path) -> "Status":
         """Return :any:`Status` with `path`."""
@@ -104,7 +104,7 @@ class FileStatus(Status):
     """Status of the Index."""
 
     work: State
-    """Status of Workiing Tree."""
+    """Status of Working Tree."""
 
     path: Path
     """File Path."""
@@ -228,7 +228,7 @@ def from_path(path: Optional[Path] = None) -> "Git":
         return Git(path=path)
 
     def is_cloned(self) -> bool:
-        """Check if clone already exists."""
+        """Return if clone already exists."""
         if not self.path.exists() or not self.path.is_dir():
             return False
         result = self._run(("rev-parse", "--show-cdup"), capture_output=True, check=False)
@@ -236,17 +236,22 @@ def is_cloned(self) -> bool:
         _LOGGER.info("Git(%r).is_cloned() = %r", str(self.path), cloned)
         return cloned
 
+    def check(self):
+        """Check clone."""
+        if not self.is_cloned():
+            raise GitCloneMissingError(self.path)
+
     def init(self, branch="main"):
         """Initialize Git Clone."""
         _LOGGER.info("Git(%r).init(branch=%r)", str(self.path), branch)
         self._run(("init", "-b", branch))
 
     def set_config(self, name, value):
-        """Configure."""
+        """Set Git Configuration `name` to `value`."""
         self._run(("config", name, value))
 
     def clone(self, url, revision=None):
-        """Clone."""
+        """Clone `url` and checkout `revision`."""
         _LOGGER.info("Git(%r).clone(%r, revision=%r)", str(self.path), url, revision)
         if revision:
             # We do not checkout, to be faster during switch later on
@@ -293,7 +298,14 @@ def get_url(self) -> Optional[str]:
         return url
 
     def checkout(self, revision: Optional[str] = None, paths: Optional[Tuple[Path, ...]] = None, force: bool = False):
-        """Checkout Revision."""
+        """
+        Checkout Revision.
+
+        Keyword Args:
+            revision: Revision to checkout.
+            paths: File Paths to checkout, otherwise entire repo.
+            force: Overwrite local changes.
+        """
         args = ["checkout"]
         if revision:
             args.append(revision)
@@ -322,8 +334,17 @@ def rebase(self):
         _LOGGER.info("Git(%r).rebase()", str(self.path))
         self._run(("rebase",))
 
-    def add(self, paths: Optional[Tuple[Path, ...]] = None, force: bool = False, all_: bool = False):
-        """Add."""
+    def add(self, paths: Tuple[Path, ...] = None, force: bool = False, all_: bool = False):
+        """
+        Add.
+
+        Args:
+            paths: File Paths to add.
+
+        Keyword Args:
+            force: allow adding otherwise ignored files.
+            all_: add changes from all tracked and untracked files.
+        """
         _LOGGER.info("Git(%r).add(%r, force=%r, all_=%r)", str(self.path), paths, force, all_)
         args = ["add"]
         if force:
@@ -334,7 +355,14 @@ def add(self, paths: Optional[Tuple[Path, ...]] = None, force: bool = False, all
 
     # pylint: disable=invalid-name
     def rm(self, paths: Tuple[Path, ...], cached: bool = False, force: bool = False, recursive: bool = False):
-        """rm."""
+        """
+        Remove.
+
+        Keyword Args:
+            cached: only remove from the index
+            force: override the up-to-date check
+            recursive: allow recursive removal
+        """
         _LOGGER.info(
             "Git(%r).rm(%r, cached=%r, force=%r, recursive=%r)", str(self.path), paths, cached, force, recursive
         )
@@ -348,12 +376,26 @@ def rm(self, paths: Tuple[Path, ...], cached: bool = False, force: bool = False,
         self._run(args, paths=paths)
 
     def reset(self, paths: Tuple[Path, ...]):
-        """Reset."""
+        """
+        Reset.
+
+        Args:
+            paths: File paths.
+        """
         _LOGGER.info("Git(%r).reset(%r)", str(self.path), paths)
         self._run(("reset",), paths=paths)
 
     def commit(self, msg, paths: Optional[Tuple[Path, ...]] = None, all_: bool = False):
-        """Commit."""
+        """
+        Commit.
+
+        Args:
+            msg: Commit Message
+
+        Keyword Args:
+            paths: Paths.
+            all_: commit all changed files
+        """
         _LOGGER.info("Git(%r).commit(%r, paths=%r, all_=%r)", str(self.path), msg, paths, all_)
         args = ["commit", "-m", msg]
         if all_:
@@ -361,7 +403,15 @@ def commit(self, msg, paths: Optional[Tuple[Path, ...]] = None, all_: bool = Fal
         self._run(args, paths=paths)
 
     def tag(self, name, msg=None):
-        """Create Tag."""
+        """
+        Create Tag.
+
+        Args:
+            name: Tag Name.
+
+        Keyword Args:
+            msg: Message.
+        """
         _LOGGER.info("Git(%r).tag(%r, msg=%r)", str(self.path), name, msg)
         args = ["tag", name]
         if msg:
@@ -369,7 +419,12 @@ def tag(self, name, msg=None):
         self._run(args)
 
     def status(self, paths: Optional[Tuple[Path, ...]] = None, branch: bool = False) -> Generator[Status, None, None]:
-        """Git Status."""
+        """
+        Git Status.
+
+        Keyword Args:
+            branch: Show branch.
+        """
         _LOGGER.info("Git(%r).status(paths=%r, branch=%r)", str(self.path), paths, branch)
         if branch:
             lines = self._run2str(("status", "--porcelain=v1", "--branch"), paths=paths).split("\n")
@@ -382,7 +437,13 @@ def status(self, paths: Optional[Tuple[Path, ...]] = None, branch: bool = False)
                 yield FileStatus.from_str(line)
 
     def diff(self, paths: Optional[Tuple[Path, ...]] = None, prefix: Path = None):
-        """Git Diff."""
+        """
+        Display Git Diff.
+
+        Keyword Args:
+            paths: Paths.
+            prefix: Path  Prefix.
+        """
         _LOGGER.info("Git(%r).diff(paths=%r, prefix=%r)", str(self.path), paths, prefix)
         if prefix:
             sep = os.path.sep
@@ -393,7 +454,12 @@ def diff(self, paths: Optional[Tuple[Path, ...]] = None, prefix: Path = None):
             self._run(("diff",))
 
     def diffstat(self, paths: Optional[Tuple[Path, ...]] = None) -> Generator[DiffStat, None, None]:
-        """Git Diff Status."""
+        """
+        Git Diff Statistics.
+
+        Keyword Args:
+            paths: Paths.
+        """
         _LOGGER.info("Git(%r).diffstat(paths=%r)", str(self.path), paths)
         lines = self._run2str(("diff", "--stat"), paths=paths).split("\n")
         if lines:
@@ -414,7 +480,14 @@ def has_changes(self) -> bool:
         return any(status.has_changes() for status in self.status())
 
     def is_empty(self):
-        """Clone does not contain any changes (untracked, staged, committed)."""
+        """
+        Clone does not contain any changes.
+
+        A clone is empty if there are:
+        * no files changed
+        * no commits which are not pushed yet
+        * nothing stashed
+        """
         _LOGGER.info("Git(%r).is_empty()", str(self.path))
         status = self._run2str(("status", "--porcelain=v1", "--branch")).split("\n")
         if len(status) > 1:
diff --git a/gitws/gitws.py b/gitws/gitws.py
index 57eafc4..20a79e6 100644
--- a/gitws/gitws.py
+++ b/gitws/gitws.py
@@ -17,7 +17,7 @@
 """
 Multi Repository Management.
 
-The :any:`GitWS` class provides a simple facade to all inner `GitWS` functionality.
+The :any:`GitWS` class provides a simple facade to all Git Workspace functionality.
 """
 import logging
 import shutil
@@ -28,13 +28,11 @@
 from ._util import no_echo, removesuffix, resolve_relative, run
 from .clone import Clone, map_paths
 from .const import MANIFEST_PATH_DEFAULT
-from .datamodel import Manifest, ManifestSpec, Project, ProjectSpec
-from .deptree import Node, get_deptree
-from .exceptions import GitCloneMissingError, GitCloneNotCleanError, InitializedError, ManifestExistError
-from .filters import Filter, default_filter
-from .git import Git, Status
+from .datamodel import GroupFilters, Manifest, ManifestSpec, Project, ProjectPaths, ProjectSpec
+from .deptree import DepNode, get_deptree
+from .exceptions import GitCloneNotCleanError, InitializedError, ManifestExistError
+from .git import DiffStat, Git, Status
 from .iters import ManifestIter, ProjectIter
-from .types import Groups, ProjectFilter
 from .workspace import Workspace
 
 _LOGGER = logging.getLogger("git-ws")
@@ -48,20 +46,42 @@ class GitWS:
     Multi Repository Management.
 
     Args:
-        workspace (Workspace): workspace.
-        manifest_spec (ManifestSpec): manifest.
+        workspace: workspace.
+        manifest_path: Manifest File Path. Relative to workspace directory.
+        group_filters: Group Filters.
+
+    Keyword Args:
+        secho: `click.secho` like print method for verbose output.
+
+    There are static methods to create a :any:`GitWS` instances in the different szenarios:
+
+    * :any:`GitWS.from_path()`: Create :any:`GitWS` for EXISTING workspace at `path`.
+    * :any:`GitWS.create()`: Create NEW workspace at `path` and return corresponding :any:`GitWS`.
+    * :any:`GitWS.init()`: Initialize NEW Workspace for git clone at `main_path` and return corresponding :any:`GitWS`.
+    * :any:`GitWS.clone()`: Clone git `url`, initialize NEW Workspace and return corresponding :any:`GitWS`.
     """
 
     # pylint: disable=too-many-public-methods
 
-    def __init__(self, workspace: Workspace, manifest_spec: ManifestSpec, secho=None):
+    def __init__(
+        self,
+        workspace: Workspace,
+        manifest_path: Path,
+        group_filters: GroupFilters,
+        secho=None,
+    ):
         self.workspace = workspace
-        self.manifest_spec = manifest_spec
+        self.manifest_path = manifest_path
+        self.group_filters = group_filters
         self.secho = secho or no_echo
 
     def __eq__(self, other):
         if isinstance(other, GitWS):
-            return (self.workspace, self.manifest_spec) == (other.workspace, other.manifest_spec)
+            return (self.workspace, self.manifest_path, self.group_filters) == (
+                other.workspace,
+                other.manifest_path,
+                other.group_filters,
+            )
         return NotImplemented
 
     @property
@@ -72,49 +92,90 @@ def path(self) -> Path:
         return self.workspace.path
 
     @staticmethod
-    def from_path(path: Optional[Path] = None, manifest_path: Optional[Path] = None, secho=None) -> "GitWS":
+    def from_path(
+        path: Optional[Path] = None,
+        manifest_path: Optional[Path] = None,
+        group_filters: Optional[GroupFilters] = None,
+        secho=None,
+    ) -> "GitWS":
         """
-        Create :any:`GitWS` for workspace at `path`.
+        Create :any:`GitWS` for EXISTING workspace at `path`.
 
         Keyword Args:
-            path:  Path within the workspace (Default is the current working directory).
+            path: Path within the workspace (Default is the current working directory).
+            mainfest_path: Manifest File Path. Relative to `main_path`. Default is taken from Configuration.
+            group_filters: Group Filters. Default is taken from Configuration.
+            secho: `click.secho` like print method for verbose output.
         """
         workspace = Workspace.from_path(path=path)
         manifest_path = workspace.get_manifest_path(manifest_path=manifest_path)
-        manifest_spec = ManifestSpec.load(manifest_path)
-        return GitWS(workspace, manifest_spec, secho=secho)
+        ManifestSpec.load(manifest_path)  # check manifest
+        if group_filters:
+            GroupFilters.validate(group_filters)
+        group_filters = workspace.get_group_filters(group_filters=group_filters or None)
+        return GitWS(workspace, manifest_path, group_filters, secho=secho)
 
     @staticmethod
-    def create(path: Path, main_path: Path, manifest_path: Path, groups: Groups = None, secho=None) -> "GitWS":
+    def create(
+        path: Path,
+        main_path: Path,
+        manifest_path: Optional[Path] = None,
+        group_filters: Optional[GroupFilters] = None,
+        secho=None,
+    ) -> "GitWS":
         """
-        Create :any:`GitWS` for workspace at `path`.
+        Create NEW workspace at `path` and return corresponding :any:`GitWS`.
+
+        Args:
+            path: Workspace Path.
+            main_path: Main Project Path.
 
         Keyword Args:
-            path:  Path within the workspace (Default is the current working directory).
-            main_path:  Main Project Path.
-            mainfest_path:  ManifestSpec File Path.
+            mainfest_path: Manifest File Path. Relative to `main_path`. Default is 'git-ws.toml'.
+                           This value is written to the configuration.
+            group_filters: Default Group Filters.
+                           This value is written to the configuration.
+            secho: `click.secho` like print method for verbose output.
         """
-        _LOGGER.debug("GitWS.create(%r, %r, %r, groups=%r)", str(path), str(main_path), str(manifest_path), groups)
+        _LOGGER.debug(
+            "GitWS.create(%r, %r, manifest_path=%r, group-filters=%r)",
+            str(path),
+            str(main_path),
+            str(manifest_path),
+            group_filters,
+        )
         # We need to resolve in inverted order, otherwise the manifest_path is broken
-        manifest_path = resolve_relative(manifest_path, base=main_path)
+        manifest_path_rel = resolve_relative(manifest_path or MANIFEST_PATH_DEFAULT, base=main_path)
         main_path = resolve_relative(main_path, base=path)
-        manifest_spec = ManifestSpec.load(path / main_path / manifest_path)
-        workspace = Workspace.init(path, main_path, manifest_path, groups=groups)
-        return GitWS(workspace, manifest_spec, secho=secho)
+        manifest_path = path / main_path / manifest_path_rel
+        ManifestSpec.load(manifest_path)  # check manifest
+        if group_filters:
+            GroupFilters.validate(group_filters)
+        workspace = Workspace.init(path, main_path, manifest_path_rel, group_filters=group_filters or None)
+        group_filters = workspace.get_group_filters(group_filters=group_filters)
+        return GitWS(workspace, manifest_path, group_filters, secho=secho)
 
     @staticmethod
     def init(
-        main_path: Path = None,
-        manifest_path: Path = MANIFEST_PATH_DEFAULT,
-        groups: Groups = None,
+        main_path: Optional[Path] = None,
+        manifest_path: Optional[Path] = None,
+        group_filters: Optional[GroupFilters] = None,
         force: bool = False,
         secho=None,
     ) -> "GitWS":
         """
-        Initialize Workspace for git clone at `main_path`.
+        Initialize NEW Workspace for git clone at `main_path` and return corresponding :any:`GitWS`.
+
+        The parent directory of `main_path` becomes the workspace directory.
 
-        :param main_path: Path within git clone. (Default is the current working directory).
-        :param manifest_path: Path to the manifest file.
+        Keyword Args:
+            main_path: Main Project Path.
+            mainfest_path: Manifest File Path. Relative to `main_path`. Default is 'git-ws.toml'.
+                           This value is written to the configuration.
+            group_filters: Default Group Filters.
+                           This value is written to the configuration.
+            force: Ignore that the workspace is not empty.
+            secho: `click.secho` like print method for verbose output.
         """
         secho = secho or no_echo
         main_path = Git.find_path(path=main_path)
@@ -125,60 +186,84 @@ def init(
         if not force:
             Workspace.check_empty(path, main_path)
         name = main_path.name
-        secho(f"===== {name} (MAIN) =====", fg=_COLOR_BANNER)
-        return GitWS.create(path, main_path, manifest_path, groups, secho=secho)
+        secho(f"===== {resolve_relative(main_path)} (MAIN {name!r}) =====", fg=_COLOR_BANNER)
+        return GitWS.create(path, main_path, manifest_path=manifest_path, group_filters=group_filters, secho=secho)
 
     def deinit(self):
-        """De-Initialize :any:`GitWS`."""
+        """
+        De-Initialize :any:`GitWS`.
+
+        The workspace is not working anymore after that. The corresponding :any:`GitWS` instance should be deleted.
+        """
         return self.workspace.deinit()
 
     @staticmethod
     def clone(
         url: str,
         main_path: Path = None,
-        manifest_path: Path = MANIFEST_PATH_DEFAULT,
-        groups: Groups = None,
+        manifest_path: Optional[Path] = None,
+        group_filters: Optional[GroupFilters] = None,
         force: bool = False,
         secho=None,
     ) -> "GitWS":
-        """Clone git `url` and initialize Workspace."""
+        """
+        Clone git `url`, initialize NEW Workspace and return corresponding :any:`GitWS`.
+
+        Args:
+            url: Main Project URL.
+
+        Keyword Args:
+            main_path: Main Project Path.
+            mainfest_path: Manifest File Path. Relative to `main_path`. Default is 'git-ws.toml'.
+                           This value is written to the configuration.
+            group_filters: Default Group Filters.
+                           This value is written to the configuration.
+            force: Ignore that the workspace is not empty.
+            secho: `click.secho` like print method for verbose output.
+        """
         secho = secho or no_echo
         parsedurl = urllib.parse.urlparse(url)
-        name = Path(parsedurl.path).name
+        name = removesuffix(Path(parsedurl.path).name, ".git")
         if main_path is None:
-            main_path = Path.cwd() / removesuffix(name, ".git")
-        main_path_rel: Path = resolve_relative(main_path)
-        path = main_path_rel.parent
+            main_path = Path.cwd() / name / name
+        main_path.parent.mkdir(parents=True, exist_ok=True)
+        path = main_path.parent
         if not force:
-            Workspace.check_empty(path, main_path_rel)
-        secho(f"===== {main_path_rel.name} (MAIN) =====", fg=_COLOR_BANNER)
+            Workspace.check_empty(path, main_path)
+        secho(f"===== {resolve_relative(main_path)} (MAIN {name!r}) =====", fg=_COLOR_BANNER)
         secho(f"Cloning {url!r}.", fg=_COLOR_ACTION)
-        git = Git(main_path_rel)
+        git = Git(resolve_relative(main_path))
         git.clone(url)
-        return GitWS.create(path, main_path_rel, manifest_path, groups, secho=secho)
+        return GitWS.create(path, main_path, manifest_path=manifest_path, group_filters=group_filters, secho=secho)
 
     def update(
         self,
-        project_paths=None,
-        manifest_path: Path = None,
-        groups: Groups = None,
+        project_paths: Optional[ProjectPaths] = None,
         skip_main: bool = False,
         prune: bool = False,
         rebase: bool = False,
         force: bool = False,
     ):
-        """Create/Update all dependent projects."""
+        """
+        Create/Update all dependent projects.
+
+        * Missing dependencies are cloned.
+        * Existing dependencies are fetched.
+        * Checkout revision from manifest
+        * Merge latest upstream changes.
+
+        Keyword Args:
+            project_paths: Limit operation to these projects.
+            skip_main: Exclude main project.
+            prune: Remove obsolete files from workspace, including non-project data!
+            rebase: Rebase instead of merge.
+            force: Enforce to prune repositories with changes.
+        """
         workspace = self.workspace
         used: List[Path] = [workspace.info.main_path]
-        for clone in self._foreach(
-            project_paths=project_paths,
-            manifest_path=manifest_path,
-            groups=groups,
-            skip_main=skip_main,
-            resolve_url=True,
-        ):
+        for clone in self._foreach(project_paths=project_paths, skip_main=skip_main, resolve_url=True):
             used.append(Path(clone.project.path))
-            self._check_clone_revision(clone, diff=False)
+            clone.check(diff=False, exists=False)
             self._update(clone, rebase)
         if prune:
             self._prune(workspace, used, force=force)
@@ -232,9 +317,8 @@ def _update(self, clone: Clone, rebase: bool):
 
     def _prune(self, workspace: Workspace, used: List[Path], force: bool = False):
         for obsolete_path in workspace.iter_obsoletes(used):
-            name = resolve_relative(obsolete_path, workspace.path)
             rel_path = resolve_relative(obsolete_path)
-            self.secho(f"===== {name} (OBSOLETE) =====", fg=_COLOR_BANNER)
+            self.secho(f"===== {rel_path} (OBSOLETE) =====", fg=_COLOR_BANNER)
             self.secho(f"Removing {str(rel_path)!r}.", fg=_COLOR_ACTION)
             git = Git(obsolete_path)
             if force or not git.is_cloned() or git.is_empty():
@@ -247,40 +331,69 @@ def status(
         paths: Optional[Tuple[Path, ...]] = None,
         branch: bool = False,
     ) -> Generator[Status, None, None]:
-        """Iterate over Status."""
+        """
+        Enriched Git Status.
+
+        Keyword Args:
+            paths: Limit Git Status to `paths` only.
+            branch: Dump branch information.
+
+        Yields:
+            Status
+        """
         for clone, cpaths in map_paths(tuple(self.clones()), paths):
             self.secho(f"===== {clone.info} =====", fg=_COLOR_BANNER)
-            self._check_clone_revision(clone)
+            clone.check()
             path = clone.git.path
             for status in clone.git.status(paths=cpaths, branch=branch):
                 yield status.with_path(path)
 
     def diff(self, paths: Optional[Tuple[Path, ...]] = None):
-        """Diff."""
+        """
+        Enriched Git Diff.
+
+        Keyword Args:
+            paths: Limit Git Diff to `paths` only.
+        """
         for clone, cpaths in map_paths(tuple(self.clones()), paths):
             self.secho(f"===== {clone.info} =====", fg=_COLOR_BANNER)
-            self._check_clone_revision(clone)
+            clone.check()
             clone.git.diff(paths=cpaths, prefix=Path(clone.project.path))
 
-    def diffstat(self, paths: Optional[Tuple[Path, ...]] = None):
-        """Diff."""
+    def diffstat(self, paths: Optional[Tuple[Path, ...]] = None) -> Generator[DiffStat, None, None]:
+        """
+        Enriched Git Diff Status.
+
+        Keyword Args:
+            paths: Limit Git Diff to `paths` only.
+
+        Yields:
+            DiffStat
+        """
         for clone, cpaths in map_paths(tuple(self.clones()), paths):
             self.secho(f"===== {clone.info} =====", fg=_COLOR_BANNER)
-            self._check_clone_revision(clone)
+            clone.check()
             path = clone.git.path
             for diffstat in clone.git.diffstat(paths=cpaths):
                 yield diffstat.with_path(path)
 
     def checkout(self, paths: Tuple[Path, ...], force: bool = False):
-        """Checkout."""
+        """
+        Enriched Git Checkout.
+
+        Keyword Args:
+            paths: Limit Checkout to `paths` only. Otherwise run checkout on all git clones.
+            force: force checkout (throw away local modifications)
+
+        """
         if paths:
             # Checkout specific files only
             for clone, cpaths in map_paths(tuple(self.clones()), paths):
                 self.secho(f"===== {clone.info} =====", fg=_COLOR_BANNER)
-                self._check_clone_revision(clone)
+                clone.check()
                 clone.git.checkout(revision=clone.project.revision, paths=cpaths, force=force)
         else:
-            # Checkout all branches
+            # Checkout all clones
             for clone in self.clones(resolve_url=True):
                 self.secho(f"===== {clone.info} =====", fg=_COLOR_BANNER)
                 git = clone.git
@@ -290,40 +403,72 @@ def checkout(self, paths: Tuple[Path, ...], force: bool = False):
                     git.clone(project.url, revision=project.revision)
                 if project.revision:
                     git.checkout(revision=project.revision, force=force)
-                self._check_clone_revision(clone)
+                clone.check(exists=False)
 
     def add(self, paths: Tuple[Path, ...], force: bool = False, all_: bool = False):
-        """Add."""
+        """
+        Add paths to index.
+
+        Args:
+            paths: Paths to be added
+
+        Keyword Args:
+            force: allow adding otherwise ignored files.
+            all_: add changes from all tracked and untracked files.
+        """
         if paths:
             for clone, cpaths in map_paths(tuple(self.clones()), paths):
+                clone.git.check()
                 clone.git.add(cpaths, force=force)
         else:
             if all_:
                 for clone in self.clones():
+                    clone.git.check()
                     clone.git.add(all_=True, force=force)
             else:
                 raise ValueError("Nothing specified, nothing added.")
 
     # pylint: disable=invalid-name
     def rm(self, paths: Tuple[Path, ...], cached: bool = False, force: bool = False, recursive: bool = False):
-        """rm."""
+        """
+        Remove.
+
+        Args:
+            paths: Paths.
+
+        Keyword Args:
+            cached: only remove from the index
+            force: override the up-to-date check
+            recursive: allow recursive removal
+        """
         if not paths:
             raise ValueError("Nothing specified, nothing removed.")
         for clone, cpaths in map_paths(tuple(self.clones()), paths):
+            clone.git.check()
             clone.git.rm(cpaths, cached=cached, force=force, recursive=recursive)
 
     def reset(self, paths: Tuple[Path, ...]):
-        """Reset."""
+        """Reset `paths`."""
         for clone, cpaths in map_paths(tuple(self.clones()), paths):
+            clone.git.check()
             clone.git.reset(cpaths)
 
     def commit(self, msg: str, paths: Tuple[Path, ...], all_: bool = False):
-        """Commit."""
+        """
+        Commit.
+
+        Args:
+            msg: Commit Message
+
+        Keyword Args:
+            paths: Paths.
+            all_: commit all changed files
+        """
         if paths:
             # clone file specific commit
             for clone, cpaths in map_paths(tuple(self.clones()), paths):
                 self.secho(f"===== {clone.info} =====", fg=_COLOR_BANNER)
-                self._check_clone_revision(clone)
+                clone.check()
                 clone.git.commit(msg, paths=cpaths, all_=all_)
         else:
             # commit changed clones
@@ -333,55 +478,55 @@ def commit(self, msg: str, paths: Tuple[Path, ...], all_: bool = False):
                 clones = [clone for clone in self.clones() if clone.git.has_index_changes()]
             for clone in clones:
                 self.secho(f"===== {clone.info} =====", fg=_COLOR_BANNER)
-                self._check_clone_revision(clone)
+                clone.check()
                 clone.git.commit(msg, all_=all_)
 
     def run_foreach(
-        self, command, project_paths=None, manifest_path: Path = None, groups: Groups = None, reverse: bool = False
+        self,
+        command,
+        project_paths: Optional[ProjectPaths] = None,
+        reverse: bool = False,
     ):
-        """Run `command` on each project."""
-        for clone in self.foreach(
-            project_paths=project_paths, manifest_path=manifest_path, groups=groups, reverse=reverse
-        ):
-            if not clone.git.is_cloned():
-                raise GitCloneMissingError(clone.git.path)
+        """
+        Run `command` on each clone.
+
+        Args:
+            command: Command to run
+
+        Keyword Args:
+            project_paths: Limit to projects only.
+            reverse: Operate in reverse order.
+        """
+        for clone in self.foreach(project_paths=project_paths, reverse=reverse):
             run(command, cwd=clone.git.path)
 
     def foreach(
-        self,
-        project_paths=None,
-        manifest_path: Path = None,
-        groups: Groups = None,
-        resolve_url: bool = False,
-        reverse: bool = False,
+        self, project_paths: Optional[ProjectPaths] = None, resolve_url: bool = False, reverse: bool = False
     ) -> Generator[Clone, None, None]:
-        """User Level Clone Iteration."""
-        for clone in self._foreach(
-            project_paths=project_paths,
-            manifest_path=manifest_path,
-            groups=groups,
-            resolve_url=resolve_url,
-            reverse=reverse,
-        ):
-            self._check_clone_revision(clone)
+        """
+        User Level Clone Iteration.
+
+        Keyword Args:
+            project_paths: Limit to projects only.
+            resolve_url: Resolve URLs to absolute ones.
+            reverse: Operate in reverse order.
+
+        Yields:
+            Clone
+        """
+        for clone in self._foreach(project_paths=project_paths, resolve_url=resolve_url, reverse=reverse):
+            clone.check()
             yield clone
 
     def _foreach(
         self,
-        project_paths=None,
-        manifest_path: Path = None,
-        groups: Groups = None,
+        project_paths: Optional[ProjectPaths] = None,
         skip_main: bool = False,
         resolve_url: bool = False,
         reverse: bool = False,
     ) -> Generator[Clone, None, None]:
-        """User Level Clone Iteration."""
         project_paths_filter = self._create_project_paths_filter(project_paths)
-        groups = self.workspace.get_groups(groups=groups)
-        filter_ = self.create_groups_filter(groups=groups)
-        clones = self.clones(manifest_path, filter_, skip_main=skip_main, resolve_url=resolve_url, reverse=reverse)
-        if groups:
-            self.secho(f"Groups: {groups!r}", bold=True)
+        clones = self.clones(skip_main=skip_main, resolve_url=resolve_url, reverse=reverse)
         for clone in clones:
             project = clone.project
             if project_paths_filter(project):
@@ -390,78 +535,70 @@ def _foreach(
             else:
                 self.secho(f"===== SKIPPING {clone.info} =====", fg=_COLOR_SKIP)
 
-    @staticmethod
-    def _check_clone_revision(clone, diff=True):
-        project = clone.project
-        projectrev = project.revision
-        if projectrev:
-            if diff:
-                try:
-                    clonerev = clone.git.get_revision()
-                except FileNotFoundError:
-                    clonerev = None
-                if clonerev and projectrev != clonerev:
-                    _LOGGER.warning("Clone %s is on different revision: %r", project.info, clonerev)
-        elif not project.is_main:
-            _LOGGER.warning("Clone %s has no revision!", project.info)
-
     def clones(
-        self,
-        manifest_path: Path = None,
-        filter_: ProjectFilter = None,
-        skip_main: bool = False,
-        resolve_url: bool = False,
-        reverse: bool = False,
+        self, skip_main: bool = False, resolve_url: bool = False, reverse: bool = False
     ) -> Generator[Clone, None, None]:
-        """Iterate over Clones."""
+        """
+        Iterate over Clones.
+
+        Keyword Args:
+            skip_main: Skip Main Repository.
+            resolve_url: Resolve URLs to absolute ones.
+            reverse: Operate in reverse order.
+
+        Yields:
+            Clone
+        """
         workspace = self.workspace
-        projects = self.projects(manifest_path, filter_, skip_main=skip_main, resolve_url=resolve_url)
+        projects = self.projects(skip_main=skip_main, resolve_url=resolve_url)
         if reverse:
             projects = reversed(tuple(projects))  # type: ignore
         for project in projects:
             clone = Clone.from_project(workspace, project)
             yield clone
 
-    def projects(
-        self,
-        manifest_path: Optional[Path] = None,
-        filter_: ProjectFilter = None,
-        skip_main: bool = False,
-        resolve_url: bool = False,
-    ) -> Generator[Project, None, None]:
-        """Iterate over Projects."""
+    def projects(self, skip_main: bool = False, resolve_url: bool = False) -> Generator[Project, None, None]:
+        """
+        Iterate over Projects.
+
+        Keyword Args:
+            skip_main: Skip Main Repository.
+            resolve_url: Resolve URLs to absolute ones.
+
+        Yields:
+            Project
+        """
         workspace = self.workspace
-        manifest_path = workspace.get_manifest_path(manifest_path=manifest_path)
-        filter_ = filter_ or self.create_groups_filter()
-        yield from ProjectIter(workspace, manifest_path, filter_=filter_, skip_main=skip_main, resolve_url=resolve_url)
+        manifest_path = self.manifest_path
+        group_filters = self.group_filters
+        yield from ProjectIter(workspace, manifest_path, group_filters, skip_main=skip_main, resolve_url=resolve_url)
 
     def manifests(
-        self, manifest_path: Optional[Path] = None, filter_: ProjectFilter = None
+        self,
     ) -> Generator[Manifest, None, None]:
         """Iterate over Manifests."""
         workspace = self.workspace
-        manifest_path = workspace.get_manifest_path(manifest_path=manifest_path)
-        filter_ = filter_ or self.create_groups_filter()
-        yield from ManifestIter(workspace, manifest_path, filter_=filter_)
+        manifest_path = self.manifest_path
+        group_filters = self.group_filters
+        yield from ManifestIter(workspace, manifest_path, group_filters)
 
     @staticmethod
     def create_manifest(manifest_path: Path = MANIFEST_PATH_DEFAULT) -> Path:
-        """Create ManifestSpec File at `manifest_path`within `project`."""
+        """Create Manifest File at `manifest_path`within `project`."""
         if manifest_path.exists():
             raise ManifestExistError(manifest_path)
         manifest_spec = ManifestSpec()
         manifest_spec.save(manifest_path)
         return manifest_path
 
-    def get_manifest_spec(self, groups: Groups = None, freeze: bool = False, resolve: bool = False) -> ManifestSpec:
+    def get_manifest_spec(self, freeze: bool = False, resolve: bool = False) -> ManifestSpec:
         """Get Manifest."""
         workspace = self.workspace
-        manifest_spec = self.manifest_spec
+        manifest_path = self.manifest_path
+        manifest_spec = ManifestSpec.load(manifest_path)
         if resolve:
             rdeps: List[ProjectSpec] = []
-            groups = self.workspace.get_groups(groups)
-            filter_ = self.create_groups_filter(groups)
-            for project in self.projects(filter_=filter_, skip_main=True):
+            for project in self.projects(skip_main=True):
                 project_spec = ProjectSpec.from_project(project)
                 rdeps.append(project_spec)
             manifest_spec = manifest_spec.update(dependencies=rdeps)
@@ -472,9 +609,8 @@ def get_manifest_spec(self, groups: Groups = None, freeze: bool = False, resolve
             fdeps: List[ProjectSpec] = []
             for project_spec, project in zip(manifest_spec.dependencies, manifest.dependencies):
                 project_path = workspace.get_project_path(project)
-                git = Git(project_path)
-                if not git.is_cloned():
-                    raise GitCloneMissingError(resolve_relative(project_path))
+                git = Git(resolve_relative(project_path))
+                git.check()
                 revision = git.get_tag() or git.get_sha()
                 fdeps.append(project_spec.update(revision=revision))
             manifest_spec = manifest_spec.update(dependencies=fdeps)
@@ -486,28 +622,20 @@ def get_manifest(self, freeze: bool = False, resolve: bool = False) -> Manifest:
         manifest_spec = self.get_manifest_spec(freeze=freeze, resolve=resolve)
         return Manifest.from_spec(manifest_spec, path=str(manifest_path))
 
-    def get_deptree(self) -> Node:
+    def get_deptree(self, primary=False) -> DepNode:
         """Get Dependency Tree."""
         manifest = self.get_manifest()
-        return get_deptree(self.workspace, manifest)
+        return get_deptree(self.workspace, manifest, primary=primary)
 
-    def _create_project_paths_filter(self, project_paths):
-        workspace_path = self.workspace.path
+    def _create_project_paths_filter(self, project_paths: Optional[ProjectPaths]):
         if project_paths:
-            project_paths = [resolve_relative(path, base=workspace_path) for path in project_paths]
-            return lambda project: resolve_relative(project.path, base=workspace_path) in project_paths
-        return default_filter
+            workspace = self.workspace
+            abspaths = [Path(project_path).resolve() for project_path in project_paths]
+            return lambda project: workspace.get_project_path(project) in abspaths
 
-    def create_groups_filter(self, groups=None):
-        """Create Filter Method for `groups`."""
-        if groups is None:
-            groups = self.workspace.get_groups()
+        def default_filter(project: Project) -> bool:
+            """Default Filter - always returning True."""
+            # pylint: disable=unused-argument
+            return True
 
-        filter_ = Filter.from_str(groups or "")
-
-        def func(project):
-            groups = [group.name for group in project.groups]
-            disabled = [group.name for group in project.groups if group.optional]
-            return filter_(groups, disabled=disabled)
-
-        return func
+        return default_filter
diff --git a/gitws/iters.py b/gitws/iters.py
index 54e8c26..9d934c6 100644
--- a/gitws/iters.py
+++ b/gitws/iters.py
@@ -16,19 +16,18 @@
 
 """:any:`Manifest` and :any:`Project` Iterators."""
 import logging
+from fnmatch import fnmatchcase
 from pathlib import Path
-from typing import Generator, List, Optional, Tuple
+from typing import Callable, Generator, List, Optional, Tuple
 
 from ._util import resolve_relative
-from .datamodel import Manifest, ManifestSpec, Project
+from .datamodel import GroupFilters, Groups, GroupSelects, Manifest, ManifestSpec, Project
 from .exceptions import ManifestNotFoundError
-from .filters import default_filter
 from .git import Git
-from .types import ProjectFilter
 from .workspace import Workspace
 
 _LOGGER = logging.getLogger("git-ws")
-_MANIFEST_DEFAULT = ManifestSpec()
+FilterFunc = Callable[[str, Groups], bool]
 
 
 class ManifestIter:
@@ -45,33 +44,37 @@ class ManifestIter:
     Args:
         workspace: The actual workspace
         manifest_path: Path to the manifest file **in the main project**.
-
-    Keyword Args:
-        filter_: Filter function. Only projects where the filter method returns `True` on, are evaluated.
+        group_filters: Group Filters.
 
     Yields:
         Manifest
     """
 
     # pylint: disable=too-few-public-methods
-    def __init__(self, workspace: Workspace, manifest_path: Path, filter_: Optional[ProjectFilter] = None):
+    def __init__(self, workspace: Workspace, manifest_path: Path, group_filters: GroupFilters):
         self.workspace: Workspace = workspace
         self.manifest_path: Path = manifest_path
-        self.filter_: ProjectFilter = filter_ or default_filter
+        self.group_filters: GroupFilters = group_filters
         self.__done: List[str] = []
 
     def __iter__(self) -> Generator[Manifest, None, None]:
-        yield from self.__iter(self.manifest_path)
-
-    def __iter(self, manifest_path: Path) -> Generator[Manifest, None, None]:
-        deps: List[Path] = []
-        done: List[str] = self.__done
-        filter_ = self.filter_
-
+        self.__done.clear()
         try:
-            manifest_spec = ManifestSpec.load(manifest_path)
+            manifest_spec = ManifestSpec.load(self.manifest_path)
         except ManifestNotFoundError:
-            return
+            pass
+        else:
+            group_filters: GroupFilters = manifest_spec.group_filters + self.group_filters  # type: ignore
+            group_selects = GroupSelects.from_group_filters(group_filters)
+            filter_ = create_filter(group_selects, default=True)
+            yield from self.__iter(self.manifest_path, manifest_spec, filter_)
+
+    def __iter(
+        self, manifest_path: Path, manifest_spec: ManifestSpec, filter_: FilterFunc
+    ) -> Generator[Manifest, None, None]:
+        deps: List[Tuple[Path, ManifestSpec, GroupSelects]] = []
+        done: List[str] = self.__done
+
         manifest = Manifest.from_spec(manifest_spec, path=str(manifest_path))
         _LOGGER.debug("%r", manifest)
         yield manifest
@@ -83,21 +86,25 @@ def __iter(self, manifest_path: Path) -> Generator[Manifest, None, None]:
                 continue
             done.append(dep_project.path)
 
-            if not filter_(dep_project):
+            if not filter_(dep_project.path, dep_project.groups):
                 _LOGGER.debug("FILTERED OUT %r", dep_project)
                 continue
 
-            _LOGGER.debug("%r", dep_project)
-            dep_project_path = self.workspace.get_project_path(dep_project)
-
             # Recursive
+            dep_project_path = self.workspace.get_project_path(dep_project)
             dep_manifest_path = dep_project_path / dep_project.manifest_path
-            if dep_manifest_path.exists():
-                deps.append(dep_manifest_path)
+            try:
+                dep_manifest_spec = ManifestSpec.load(dep_manifest_path)
+            except ManifestNotFoundError:
+                pass
+            else:
+                group_selects = GroupSelects.from_groups(dep_project.with_groups)
+                deps.append((dep_manifest_path, dep_manifest_spec, group_selects))
 
         # We resolve all dependencies in a second iteration to prioritize the manifest
-        for dep_manifest_path in deps:
-            yield from self.__iter(dep_manifest_path)
+        for dep_manifest_path, dep_manifest_spec, dep_group_selects in deps:
+            dep_filter = create_filter(dep_group_selects)
+            yield from self.__iter(dep_manifest_path, dep_manifest_spec, dep_filter)
 
 
 class ProjectIter:
@@ -115,9 +122,9 @@ class ProjectIter:
     Args:
         workspace: The actual workspace
         manifest_path: Path to the manifest file **in the main project**.
+        group_filters: Group Filters.
 
     Keyword Args:
-        filter_: Filter function. Only projects where the filter method returns `True` on, are evaluated.
         skip_main: Do not yield main project.
         resolve_url: Resolve relative URLs to absolute ones.
 
@@ -131,13 +138,13 @@ def __init__(
         self,
         workspace: Workspace,
         manifest_path: Path,
-        filter_: Optional[ProjectFilter] = None,
+        group_filters: GroupFilters,
         skip_main: bool = False,
         resolve_url: bool = False,
     ):
         self.workspace: Workspace = workspace
         self.manifest_path: Path = manifest_path
-        self.filter_: ProjectFilter = filter_ or default_filter
+        self.group_filters: GroupFilters = group_filters
         self.skip_main: bool = skip_main
         self.resolve_url: bool = resolve_url
         self.__done: List[str] = []
@@ -147,20 +154,23 @@ def __iter__(self) -> Generator[Project, None, None]:
         info = workspace.info
         self.__done = [str(info.main_path)]
         if not self.skip_main:
-            project = Project(name=info.main_path.name, path=str(info.main_path), is_main=True)
-            if self.filter_(project):
-                yield project
+            yield Project(name=info.main_path.name, path=str(info.main_path), is_main=True)
         try:
             manifest_spec = ManifestSpec.load(self.manifest_path)
         except ManifestNotFoundError:
             pass
         else:
-            yield from self.__iter(self.workspace.main_path, manifest_spec)
-
-    def __iter(self, project_path: Path, manifest_spec: ManifestSpec) -> Generator[Project, None, None]:
-        deps: List[Tuple[Path, ManifestSpec]] = []
+            group_filters: GroupFilters = manifest_spec.group_filters + self.group_filters  # type: ignore
+            group_selects = GroupSelects.from_group_filters(group_filters)
+            filter_ = create_filter(group_selects, default=True)
+            yield from self.__iter(self.workspace.main_path, manifest_spec, filter_)
+
+    def __iter(
+        self, project_path: Path, manifest_spec: ManifestSpec, filter_: FilterFunc
+    ) -> Generator[Project, None, None]:
+        # pylint: disable=too-many-locals
+        deps: List[Tuple[Path, ManifestSpec, GroupSelects]] = []
         refurl: Optional[str] = None
-        filter_ = self.filter_
         done: List[str] = self.__done
         if self.resolve_url and manifest_spec.dependencies:
             git = Git(resolve_relative(project_path))
@@ -177,8 +187,7 @@ def __iter(self, project_path: Path, manifest_spec: ManifestSpec) -> Generator[P
                 continue
             done.append(dep_project.path)
 
-            dep_project_path = self.workspace.get_project_path(dep_project)
-            if not filter_(dep_project):
+            if not filter_(dep_project.path, dep_project.groups):
                 _LOGGER.debug("FILTERED OUT %r", dep_project)
                 continue
 
@@ -186,14 +195,91 @@ def __iter(self, project_path: Path, manifest_spec: ManifestSpec) -> Generator[P
             yield dep_project
 
             # Recursive
+            dep_project_path = self.workspace.get_project_path(dep_project)
             dep_manifest_path = dep_project_path / dep_project.manifest_path
             try:
                 dep_manifest = ManifestSpec.load(dep_manifest_path)
             except ManifestNotFoundError:
                 pass
             else:
-                deps.append((dep_project_path, dep_manifest))
+                group_selects = GroupSelects.from_groups(dep_project.with_groups)
+                deps.append((dep_project_path, dep_manifest, group_selects))
 
         # We resolve all dependencies in a second iteration to prioritize the manifest
-        for dep_project_path, dep_manifest in deps:
-            yield from self.__iter(dep_project_path, dep_manifest)
+        for dep_project_path, dep_manifest, dep_group_selects in deps:
+            dep_filter = create_filter(dep_group_selects)
+            yield from self.__iter(dep_project_path, dep_manifest, dep_filter)
+
+
+def create_filter(group_selects: GroupSelects, default: bool = False) -> FilterFunc:
+    """
+    Create Group Filter Function.
+
+    Filter projects based on their `path` and `groups`.
+    The filter has `group_selects`. A specification which groups should be included or excluded.
+    The default selection of these groups is controlled by `default`.
+
+    Keyword Args:
+        group_selects: Iterable with :any`GroupSelect`.
+        default: Default selection of all `groups`.
+
+    >>> group_filters=('+test', '+doc', '+feature@dep', '-doc')
+    >>> group_selects = GroupSelects.from_group_filters(group_filters)
+    >>> groupfilter = create_filter(group_selects)
+    >>> groupfilter('sub', tuple())
+    True
+    >>> groupfilter('sub', ('foo', 'bar'))
+    False
+    >>> groupfilter('sub', ('test',))
+    True
+    >>> groupfilter('sub', ('doc',))
+    False
+    >>> groupfilter('sub', ('test', 'doc'))
+    True
+    >>> groupfilter('sub', ('feature',))
+    False
+    >>> groupfilter('dep', ('feature',))
+    True
+
+    >>> groupfilter = create_filter(group_selects, default=True)
+    >>> groupfilter('sub', tuple())
+    True
+    >>> groupfilter('sub', ('foo', 'bar'))
+    True
+    >>> groupfilter('sub', ('test',))
+    True
+    >>> groupfilter('sub', ('doc',))
+    False
+    >>> groupfilter('sub', ('test', 'doc'))
+    True
+    >>> groupfilter('sub', ('feature',))
+    True
+    >>> groupfilter('dep', ('feature',))
+    True
+    """
+
+    if group_selects:
+
+        def filter_(path: str, groups: Groups):
+            if groups:
+                selects = {group: default for group in groups}
+                for group_select in group_selects:
+                    if group_select.group not in selects:
+                        # not relevant group name
+                        continue
+                    if group_select.path and not fnmatchcase(path, group_select.path):
+                        # not relevant path
+                        continue
+                    selects[group_select.group] = group_select.select
+                return any(selects.values())
+            return True
+
+    else:
+
+        def filter_(path: str, groups: Groups):
+            # pylint: disable=unused-argument
+            if groups:
+                return default
+            return True
+
+    return filter_
diff --git a/gitws/types.py b/gitws/types.py
deleted file mode 100644
index 4dc3926..0000000
--- a/gitws/types.py
+++ /dev/null
@@ -1,26 +0,0 @@
-# Copyright 2022 c0fec0de
-#
-# This file is part of Git Workspace.
-#
-# Git Workspace is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Lesser General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# Git Workspace is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public License along
-# with Git Workspace. If not, see <https://www.gnu.org/licenses/>.
-
-"""General Types."""
-from typing import Callable, Optional
-
-from .datamodel import Project
-
-Groups = Optional[str]
-"""Groups Specification."""
-
-ProjectFilter = Callable[[Project], bool]
-"""Project Filter Function."""
diff --git a/gitws/workspace.py b/gitws/workspace.py
index b671d1a..350cfe4 100644
--- a/gitws/workspace.py
+++ b/gitws/workspace.py
@@ -31,9 +31,8 @@
 from ._util import resolve_relative
 from .appconfig import AppConfig, AppConfigData, AppConfigLocation
 from .const import GIT_WS_PATH, INFO_PATH, MANIFEST_PATH_DEFAULT
-from .datamodel import Project
+from .datamodel import GroupFilters, Project
 from .exceptions import InitializedError, OutsideWorkspaceError, UninitializedError, WorkspaceNotEmptyError
-from .types import Groups
 from .workspacefinder import find_workspace
 
 _LOGGER = logging.getLogger("git-ws")
@@ -63,7 +62,7 @@ def load(path: Path) -> "Info":
         The workspace information is stored at `{path}/.gitws/info.toml`.
 
         Args:
-            path (Path): Path to GitWS root directory.
+            path: Path to GitWS root directory.
         """
         infopath = path / INFO_PATH
         doc = tomlkit.parse(infopath.read_text())
@@ -78,7 +77,7 @@ def save(self, path: Path):
         The workspace information is stored at `{path}/.gitws/info.toml`.
 
         Args:
-            path (Path): Path to GitWS root directory.
+            path: Path to GitWS root directory.
         """
         infopath = path / INFO_PATH
         infopath.parent.mkdir(parents=True, exist_ok=True)
@@ -102,8 +101,8 @@ class Workspace:
     A workspace refers to a main git clone, which defines the workspace content (i.e. dependencies).
 
     Args:
-        path (Path): Workspace Root Directory.
-        info (Info): Workspace Information.
+        path: Workspace Root Directory.
+        info: Workspace Information.
     """
 
     def __init__(self, path: Path, info: Info):
@@ -152,7 +151,8 @@ def from_path(path=None) -> "Workspace":
         path = Workspace.find_path(path=path)
         info = Info.load(path)
         workspace = Workspace(path, info)
-        _LOGGER.info("Workspace path=%s main=%s %r", path, info.main_path, workspace.config)
+        _LOGGER.info("Workspace path=%s main=%s", path, info.main_path)
+        _LOGGER.info("%r", workspace.config)
         return workspace
 
     @staticmethod
@@ -171,17 +171,18 @@ def check_empty(path: Path, main_path: Path):
 
     @staticmethod
     def init(
-        path: Path, main_path: Path, manifest_path: Path = MANIFEST_PATH_DEFAULT, groups: Groups = None
+        path: Path, main_path: Path, manifest_path: Optional[Path] = None, group_filters: Optional[GroupFilters] = None
     ) -> "Workspace":
         """
         Initialize new :any:`Workspace` at `path`.
 
         Args:
-            path (Path):  Path to the workspace
-            main_path (Path):  Path to the main project. Relative to `path`.
+            path:  Path to the workspace
+            main_path:  Path to the main project. Relative to `path`.
 
         Keyword Args:
-            manifest_path (Path):  Path to the manifest file. Relative to `main_path`.
+            manifest_path:  Path to the manifest file. Relative to `main_path`. Default is `git-ws.toml`.
+            group_filters: Group Filters.
 
         Raises:
             OutsideWorkspaceError: `main_path` is not within `path`.
@@ -201,9 +202,10 @@ def init(
         info.save(path)
         workspace = Workspace(path.resolve(), info)
         with workspace.app_config.edit(AppConfigLocation.WORKSPACE) as config:
-            config.manifest_path = str(manifest_path)
-            config.groups = groups
-        _LOGGER.info("Initialized %s %r %r", path, info, workspace.config)
+            config.manifest_path = str(manifest_path or MANIFEST_PATH_DEFAULT)
+            config.group_filters = group_filters
+        _LOGGER.info("Workspace path=%s main=%s", path, info.main_path)
+        _LOGGER.info("%r", workspace.config)
         return workspace
 
     def deinit(self):
@@ -216,7 +218,7 @@ def deinit(self):
 
     @property
     def main_path(self) -> Path:
-        """Path to main project."""
+        """Resolved Path to main project."""
         return self.path / self.info.main_path
 
     @property
@@ -240,14 +242,14 @@ def get_project_path(self, project: Project, relative: bool = False) -> Path:
         return project_path
 
     def get_manifest_path(self, manifest_path: Optional[Path] = None) -> Path:
-        """Get Manifest Path."""
+        """Get Resolved Manifest Path."""
         return self.main_path / (manifest_path or self.app_config.options.manifest_path or MANIFEST_PATH_DEFAULT)
 
-    def get_groups(self, groups: Groups = None) -> Groups:
-        """Get Groups Filter."""
-        if groups is None:
-            return self.app_config.options.groups
-        return groups
+    def get_group_filters(self, group_filters: Optional[GroupFilters] = None) -> GroupFilters:
+        """Get Group Selects."""
+        if group_filters is None:
+            return self.app_config.options.group_filters or GroupFilters()
+        return group_filters
 
     def iter_obsoletes(self, used: List[Path]) -> Generator[Path, None, None]:
         """Yield obsolete paths except `used` ones."""


Test patch:
diff --git a/tests/common.py b/tests/common.py
index 20ff907..711d480 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -15,151 +15,32 @@
 # with Git Workspace. If not, see <https://www.gnu.org/licenses/>.
 
 """Shared Test Stuff."""
-MANIFEST_DEFAULT = """version = "1.0"
+MANIFEST_DEFAULT = """\
+version = "1.0"
 ##
-## Welcome to Git Workspace's Manifest. It actually contains 4 parts:
+## Git Workspace\'s Manifest. Please see the documentation at:
 ##
-## * Remotes
-## * Groups
-## * Defaults
-## * Dependencies
+## https://git-ws.readthedocs.io/en/latest/manual/manifest.html
 ##
-## =========
-##  Remotes
-## =========
-##
-## Remotes just refer to a directory with repositories.
-##
-## We support relative paths for dependencies. So, if your dependencies are next
-## to your repository, you might NOT need any remote.
-## In other terms: You only need remotes if your dependencies are located on
-## OTHER servers than your server with this manifest.
-##
-## Remotes have two attributes:
-## * name: Required. String.
-##         Name of the remote. Any valid string. Must be unique within your
-##         manifest.
-## * url-base: Required. String.
-##             URL Prefix. The project 'name' or 'sub-url' will be appended
-##             later-on.
-##
-# [[remotes]]
-# name = "myremote"
-# url-base = "https://github.com/myuser"
 
 
-## =========
-##  Groups
-## =========
-##
-## Groups structure dependencies.
-##
-## Groups are optional by default.
-## If a dependency belongs to a group, it becomes optional likewise.
-## Groups can be later on selected/deselected by '+group' or '-group'.
-## An optional group can be selected by '+group',
-## a non-optional group can be deselected by '-group'.
-## Deselection has higher priority than selection.
-##
-## Dependencies can refer to non-existing groups. You do NOT need to specify
-## all used groups.
-##
-## Groups have two attributes:
-## * name: Required. String.
-##         Name of the group. Any valid string. Must be unique within your
-##         manifest.
-## * optional: Optional. Bool. Default is True.
-##             Specifies if the group is optional. Meaning it must be selected
-##             explicitly. Otherwise the dependency is not added by default.
-##
-## The following lines set a group as non-optional.
-# [[groups]]
-# name = "test"
-# optional = false
+# group-filters = ["+test", "-doc", "+feature@path"]
+group-filters = []
 
 
-## ==========
-##  Defaults
-## ==========
-##
-## The 'defaults' section specifies default values for dependencies.
-##
-## * remote: Optional. String.
-##           Remote used as default.
-##           The 'remote' MUST be defined in the 'remotes' section above!
-## * revision: Optional. String.
-##             Revision used as default. Tag or Branch.
-##
-## NOTE: It is recommended to specify a default revision (i.e. 'main').
-##       If a dependency misses 'revision', GitWS will not take care about
-##       revision handling. This may lead to strange side-effects. You
-##       have been warned.
+# [[remotes]]
+# name = "myremote"
+# url-base = "https://github.com/myuser"
+
 
 [defaults]
 # remote = "myserver"
 # revision = "main"
+# groups = ["+test"]
+# with_groups = ["doc"]
 
 
-## ==============
-##  Dependencies
-## ==============
-##
-## The 'dependencies' section specifies all your git clones you need for your
-## project to operate.
-##
-## A dependency has the following attributes:
-## * name: Required. String.
-##         Just name your dependency. It is recommended to choose a
-##         unique name, but not a must.
-## * remote: Optional. String. Restricted (see RESTRICTIONS below).
-##           Remote Alias.
-##           The 'remote' MUST be defined in the 'remotes' section above!
-##           The 'remote' can also be specified in the 'defaults' section.
-## * sub-url: Optional. String. Default: '../{name}[.git]' (see NOTE1 below).
-##            Relative URL to 'url-base' of your specified 'remote'
-##            OR
-##            Relative URL to the URL of the repository containing this
-##            manifest.
-## * url: Optional. String. Restricted (see RESTRICTIONS below).
-##        Absolute URL to the dependent repository.
-## * revision: Optional. String.
-##             Revision to be checked out.
-##             If this attribute is left blank, GitWS does NOT manage the
-##             dependency revision (see NOTE2 below)!
-##             The 'revision' can also be specified in the 'defaults' section.
-## * path: Optional. String. Default is '{name}'.
-##         Project Filesystem Path. Relative to Workspace Root Directory.
-##         The dependency 'name' is used as default for 'path'.
-##         The 'path' MUST be unique within your manifest.
-## * manifest_path: Optional. String. Default: 'git-ws.toml'.
-##                   Path to manifest.
-##                   Relative to 'path'.
-##                   Avoid changing it! It is just additional effort.
-## * groups: Optional. List of Strings.
-##           Dependency Groups.
-##           Dependencies can be categorized into groups.
-##           Groups are optional by default. See 'groups' section above.
-##
-## NOTE1: 'sub-url' is '../{name}[.git]' by default. Meaning if the dependency
-##        is next to your repository containing this manifest, the dependency
-##        is automatically found.
-##        The '.git' suffix is appended if the repository containing this
-##        manifest uses a '.git' suffix.
-##
-## NOTE2: It is recommended to specify a revision (i.e. 'main') either
-##        explicitly or via the 'default' section.
-##        Without a 'revision' GitWS will not take care about revision
-##        handling. This may lead to strange side-effects.
-##        You have been warned.
-##
-## RESTRICTIONS:
-##
-## * `remote` and `url` are mutually exclusive.
-## * `url` and `sub-url` are likewise mutually exclusive
-## * `sub-url` requires a `remote`.
-##
-##
-## A full flavored dependency using a 'remote':
+## A full flavored dependency using a \'remote\':
 # [[dependencies]]
 # name = "myname"
 # remote = "remote"
@@ -168,7 +49,7 @@
 # path = "mydir"
 # groups = ["group"]
 
-## A full flavored dependency using a 'url':
+## A full flavored dependency using a \'url\':
 # [[dependencies]]
 # name = "myname"
 # url = "https://github.com/myuser/my.git"
diff --git a/tests/fixtures.py b/tests/fixtures.py
index 6d502bf..a511fd4 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -19,7 +19,7 @@
 
 from pytest import fixture
 
-from gitws.datamodel import Defaults, Group, ManifestSpec, ProjectSpec
+from gitws.datamodel import Defaults, ManifestSpec, ProjectSpec
 
 from .util import chdir, run
 
@@ -46,20 +46,18 @@ def repos(tmp_path):
     with git_repo(repos_path / "main", commit="initial") as path:
         (path / "data.txt").write_text("main")
         ManifestSpec(
+            group_filters=("-test",),
             dependencies=[
                 ProjectSpec(name="dep1", url="../dep1"),
                 ProjectSpec(name="dep2", url="../dep2", revision="1-feature"),
+                ProjectSpec(name="dep3", url="../dep3", groups=("test",)),
             ],
         ).save(path / "git-ws.toml")
 
     with chdir(repos_path / "main"):
         ManifestSpec(
             defaults=Defaults(revision="main"),
-            groups=[
-                Group(name="foo", optional=False),
-                Group(name="bar", optional=False),
-                Group(name="fast", optional=False),
-            ],
+            group_filters=("+foo", "+bar", "-fast"),
             dependencies=[
                 ProjectSpec(name="dep1", url="../dep1"),
                 ProjectSpec(name="dep6", url="../dep6", path="sub/dep6", groups=["foo", "bar", "fast"]),
@@ -81,7 +79,7 @@ def repos(tmp_path):
         (path / "data.txt").write_text("dep2")
         ManifestSpec(
             defaults=Defaults(revision="main"),
-            groups=[Group(name="test", optional=True)],
+            group_filters=("-test",),
             dependencies=[
                 ProjectSpec(name="dep3", url="../dep3", groups=("test",)),
                 ProjectSpec(name="dep4", url="../dep4", revision="main"),
diff --git a/tests/test_api.py b/tests/test_api.py
index eb3d973..fa1a94e 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -26,11 +26,14 @@ def test_imports():
         AppConfigLocation,
         Clone,
         Defaults,
-        Filter,
         Git,
         GitCloneMissingError,
         GitWS,
         Group,
+        GroupFilter,
+        GroupFilters,
+        GroupSelect,
+        GroupSelects,
         InitializedError,
         InvalidConfigurationFileError,
         InvalidConfigurationLocationError,
diff --git a/tests/test_cli.py b/tests/test_cli_basic.py
similarity index 76%
rename from tests/test_cli.py
rename to tests/test_cli_basic.py
index 6685c5b..3790512 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli_basic.py
@@ -29,13 +29,13 @@
 @fixture
 def gws(tmp_path, repos):
     """Initialized :any:`GitWS` on `repos`."""
-    workspace = tmp_path / "workspace"
-    workspace.mkdir()
+    workspace = tmp_path / "main"
 
-    with chdir(workspace):
+    with chdir(tmp_path):
         gws = GitWS.clone(str(repos / "main"))
         gws.update(skip_main=True)
 
+    with chdir(workspace):
         yield gws
 
 
@@ -48,11 +48,11 @@ def test_push(tmp_path, gws):
     """Test push."""
     # pylint: disable=unused-argument
     assert cli(("push",)) == [
-        "===== dep4 (revision='main') =====",
-        "===== dep2 (revision='1-feature') =====",
-        "===== dep1 =====",
+        "===== dep4 ('dep4', revision='main') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== main (MAIN) =====",
+        "===== main (MAIN 'main') =====",
         "",
     ]
 
@@ -77,8 +77,8 @@ def test_deinit(tmp_path, gws):
     # pylint: disable=unused-argument
     assert cli(["deinit"]) == ["Workspace deinitialized at '.'.", ""]
 
-    assert not (tmp_path / "workspace/.gitws").exists()
-    assert (tmp_path / "workspace/main").exists()
+    assert not (tmp_path / "main/.gitws").exists()
+    assert (tmp_path / "main/main").exists()
 
     assert cli(["deinit"], exit_code=1) == [
         "Error: git workspace has not been initialized yet. Try:",
@@ -97,19 +97,19 @@ def test_git(tmp_path, gws):
     """Test git."""
     # pylint: disable=unused-argument
     assert cli(["git", "status"]) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
-        "===== dep4 (revision='main') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "",
     ]
 
     assert cli(["git", "status", "-P", "dep2", "-P", "./dep4"]) == [
-        "===== SKIPPING main (MAIN) =====",
-        "===== SKIPPING dep1 =====",
-        "===== dep2 (revision='1-feature') =====",
-        "===== dep4 (revision='main') =====",
+        "===== SKIPPING main (MAIN 'main') =====",
+        "===== SKIPPING dep1 ('dep1') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "",
     ]
 
@@ -118,54 +118,56 @@ def test_foreach(tmp_path, gws, caplog):
     """Test foreach."""
     # pylint: disable=unused-argument
     assert cli(["foreach", "git", "status"]) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
-        "===== dep4 (revision='main') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "",
     ]
     assert format_logs(caplog, tmp_path) == [
-        "INFO    git-ws Workspace path=TMP/workspace main=main "
-        "AppConfigData(manifest_path='git-ws.toml', color_ui=True, groups=None)",
+        "INFO    git-ws Workspace path=TMP/main main=main",
+        "INFO    git-ws AppConfigData(manifest_path='git-ws.toml', color_ui=True, group_filters=None)",
         "DEBUG   git-ws run(['git', 'rev-parse', '--show-cdup'], cwd='main') OK stdout=b'\\n' stderr=b''",
         "INFO    git-ws Git('main').is_cloned() = True",
         "DEBUG   git-ws run(('git', 'status'), cwd='main') OK stdout=None stderr=None",
-        "DEBUG   git-ws ManifestSpec(dependencies=(ProjectSpec(name='dep1', "
-        "url='../dep1'), ProjectSpec(name='dep2', url='../dep2', "
-        "revision='1-feature')))",
+        "DEBUG   git-ws ManifestSpec(group_filters=('-test',), "
+        "dependencies=(ProjectSpec(name='dep1', url='../dep1'), "
+        "ProjectSpec(name='dep2', url='../dep2', revision='1-feature'), "
+        "ProjectSpec(name='dep3', url='../dep3', groups=('test',))))",
         "DEBUG   git-ws Project(name='dep1', path='dep1', url='../dep1')",
-        "WARNING git-ws Clone dep1 has no revision!",
         "DEBUG   git-ws run(['git', 'rev-parse', '--show-cdup'], cwd='dep1') OK stdout=b'\\n' stderr=b''",
         "INFO    git-ws Git('dep1').is_cloned() = True",
+        "WARNING git-ws Clone dep1 has no revision!",
         "DEBUG   git-ws run(('git', 'status'), cwd='dep1') OK stdout=None stderr=None",
         "DEBUG   git-ws Project(name='dep2', path='dep2', url='../dep2', revision='1-feature')",
+        "DEBUG   git-ws run(['git', 'rev-parse', '--show-cdup'], cwd='dep2') OK stdout=b'\\n' stderr=b''",
+        "INFO    git-ws Git('dep2').is_cloned() = True",
         "DEBUG   git-ws run(['git', 'describe', '--exact-match', '--tags'], "
         "cwd='dep2') OK stdout=b'' stderr=b'fatal: No names found, cannot describe "
         "anything.\\n'",
         "INFO    git-ws Git('dep2').get_tag() = None",
         "DEBUG   git-ws run(['git', 'branch', '--show-current'], cwd='dep2') OK stdout=b'1-feature\\n' stderr=b''",
         "INFO    git-ws Git('dep2').get_branch() = '1-feature'",
-        "DEBUG   git-ws run(['git', 'rev-parse', '--show-cdup'], cwd='dep2') OK stdout=b'\\n' stderr=b''",
-        "INFO    git-ws Git('dep2').is_cloned() = True",
         "DEBUG   git-ws run(('git', 'status'), cwd='dep2') OK stdout=None stderr=None",
+        "DEBUG   git-ws FILTERED OUT Project(name='dep3', path='dep3', url='../dep3', groups=('test',))",
         "DEBUG   git-ws ManifestSpec(dependencies=(ProjectSpec(name='dep4', url='../dep4', revision='main'),))",
         "DEBUG   git-ws Project(name='dep4', path='dep4', url='../dep4', revision='main')",
+        "DEBUG   git-ws run(['git', 'rev-parse', '--show-cdup'], cwd='dep4') OK stdout=b'\\n' stderr=b''",
+        "INFO    git-ws Git('dep4').is_cloned() = True",
         "DEBUG   git-ws run(['git', 'describe', '--exact-match', '--tags'], "
         "cwd='dep4') OK stdout=b'' stderr=b'fatal: No names found, cannot describe "
         "anything.\\n'",
         "INFO    git-ws Git('dep4').get_tag() = None",
         "DEBUG   git-ws run(['git', 'branch', '--show-current'], cwd='dep4') OK stdout=b'main\\n' stderr=b''",
         "INFO    git-ws Git('dep4').get_branch() = 'main'",
-        "DEBUG   git-ws run(['git', 'rev-parse', '--show-cdup'], cwd='dep4') OK stdout=b'\\n' stderr=b''",
-        "INFO    git-ws Git('dep4').is_cloned() = True",
         "DEBUG   git-ws run(('git', 'status'), cwd='dep4') OK stdout=None stderr=None",
-        "DEBUG   git-ws ManifestSpec(groups=(Group(name='test'),), "
+        "DEBUG   git-ws ManifestSpec(group_filters=('-test',), "
         "defaults=Defaults(revision='main'), dependencies=(ProjectSpec(name='dep3', "
         "url='../dep3', groups=('test',)), ProjectSpec(name='dep4', url='../dep4', "
         "revision='main')))",
-        "DEBUG   git-ws FILTERED OUT Project(name='dep3', path='dep3', url='../dep3', "
-        "revision='main', groups=(Group(name='test'),))",
+        "DEBUG   git-ws DUPLICATE Project(name='dep3', path='dep3', url='../dep3', "
+        "revision='main', groups=('test',))",
         "DEBUG   git-ws DUPLICATE Project(name='dep4', path='dep4', url='../dep4', revision='main')",
     ]
 
@@ -173,12 +175,12 @@ def test_foreach(tmp_path, gws, caplog):
 def test_foreach_missing(tmp_path, gws, caplog):
     """Test foreach."""
     # pylint: disable=unused-argument
-    rmtree(tmp_path / "workspace" / "dep2")
+    rmtree(tmp_path / "main" / "dep2")
     assert cli(["foreach", "git", "status"], exit_code=1) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "Error: Git Clone 'dep2' is missing. Try:",
         "",
         "    git ws update",
@@ -191,7 +193,7 @@ def test_foreach_fail(tmp_path, gws):
     """Test foreach failing."""
     # pylint: disable=unused-argument
     assert cli(["foreach", "--", "git", "status", "--invalidoption"], exit_code=1) == [
-        "===== main (MAIN) =====",
+        "===== main (MAIN 'main') =====",
         "Error: Command '('git', 'status', '--invalidoption')' returned non-zero exit status 129.",
         "",
     ]
@@ -217,11 +219,11 @@ def test_outside(tmp_path, gws):
 def _test_foreach(tmp_path, gws, *command):
     # pylint: disable=unused-argument
     assert cli(command) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
-        "===== dep4 (revision='main') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "",
     ]
 
@@ -231,53 +233,55 @@ def test_git_no_color(tmp_path, gws, caplog):
     # pylint: disable=unused-argument
     assert cli(["config", "set", "color_ui", "False"]) == [""]
     assert cli(["git", "status"]) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
-        "===== dep4 (revision='main') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "",
     ]
     assert format_logs(caplog, tmp_path) == [
-        "INFO    git-ws Workspace path=TMP/workspace main=main "
-        "AppConfigData(manifest_path='git-ws.toml', color_ui=False, groups=None)",
+        "INFO    git-ws Workspace path=TMP/main main=main",
+        "INFO    git-ws AppConfigData(manifest_path='git-ws.toml', color_ui=False, group_filters=None)",
         "DEBUG   git-ws run(['git', 'rev-parse', '--show-cdup'], cwd='main') OK stdout=b'\\n' stderr=b''",
         "INFO    git-ws Git('main').is_cloned() = True",
         "DEBUG   git-ws run(('git', 'status'), cwd='main') OK stdout=None stderr=None",
-        "DEBUG   git-ws ManifestSpec(dependencies=(ProjectSpec(name='dep1', "
-        "url='../dep1'), ProjectSpec(name='dep2', url='../dep2', "
-        "revision='1-feature')))",
+        "DEBUG   git-ws ManifestSpec(group_filters=('-test',), "
+        "dependencies=(ProjectSpec(name='dep1', url='../dep1'), "
+        "ProjectSpec(name='dep2', url='../dep2', revision='1-feature'), "
+        "ProjectSpec(name='dep3', url='../dep3', groups=('test',))))",
         "DEBUG   git-ws Project(name='dep1', path='dep1', url='../dep1')",
-        "WARNING git-ws Clone dep1 has no revision!",
         "DEBUG   git-ws run(['git', 'rev-parse', '--show-cdup'], cwd='dep1') OK stdout=b'\\n' stderr=b''",
         "INFO    git-ws Git('dep1').is_cloned() = True",
+        "WARNING git-ws Clone dep1 has no revision!",
         "DEBUG   git-ws run(('git', 'status'), cwd='dep1') OK stdout=None stderr=None",
         "DEBUG   git-ws Project(name='dep2', path='dep2', url='../dep2', revision='1-feature')",
+        "DEBUG   git-ws run(['git', 'rev-parse', '--show-cdup'], cwd='dep2') OK stdout=b'\\n' stderr=b''",
+        "INFO    git-ws Git('dep2').is_cloned() = True",
         "DEBUG   git-ws run(['git', 'describe', '--exact-match', '--tags'], "
         "cwd='dep2') OK stdout=b'' stderr=b'fatal: No names found, cannot describe "
         "anything.\\n'",
         "INFO    git-ws Git('dep2').get_tag() = None",
         "DEBUG   git-ws run(['git', 'branch', '--show-current'], cwd='dep2') OK stdout=b'1-feature\\n' stderr=b''",
         "INFO    git-ws Git('dep2').get_branch() = '1-feature'",
-        "DEBUG   git-ws run(['git', 'rev-parse', '--show-cdup'], cwd='dep2') OK stdout=b'\\n' stderr=b''",
-        "INFO    git-ws Git('dep2').is_cloned() = True",
         "DEBUG   git-ws run(('git', 'status'), cwd='dep2') OK stdout=None stderr=None",
+        "DEBUG   git-ws FILTERED OUT Project(name='dep3', path='dep3', url='../dep3', groups=('test',))",
         "DEBUG   git-ws ManifestSpec(dependencies=(ProjectSpec(name='dep4', url='../dep4', revision='main'),))",
         "DEBUG   git-ws Project(name='dep4', path='dep4', url='../dep4', revision='main')",
+        "DEBUG   git-ws run(['git', 'rev-parse', '--show-cdup'], cwd='dep4') OK stdout=b'\\n' stderr=b''",
+        "INFO    git-ws Git('dep4').is_cloned() = True",
         "DEBUG   git-ws run(['git', 'describe', '--exact-match', '--tags'], "
         "cwd='dep4') OK stdout=b'' stderr=b'fatal: No names found, cannot describe "
         "anything.\\n'",
         "INFO    git-ws Git('dep4').get_tag() = None",
         "DEBUG   git-ws run(['git', 'branch', '--show-current'], cwd='dep4') OK stdout=b'main\\n' stderr=b''",
         "INFO    git-ws Git('dep4').get_branch() = 'main'",
-        "DEBUG   git-ws run(['git', 'rev-parse', '--show-cdup'], cwd='dep4') OK stdout=b'\\n' stderr=b''",
-        "INFO    git-ws Git('dep4').is_cloned() = True",
         "DEBUG   git-ws run(('git', 'status'), cwd='dep4') OK stdout=None stderr=None",
-        "DEBUG   git-ws ManifestSpec(groups=(Group(name='test'),), "
+        "DEBUG   git-ws ManifestSpec(group_filters=('-test',), "
         "defaults=Defaults(revision='main'), dependencies=(ProjectSpec(name='dep3', "
         "url='../dep3', groups=('test',)), ProjectSpec(name='dep4', url='../dep4', "
         "revision='main')))",
-        "DEBUG   git-ws FILTERED OUT Project(name='dep3', path='dep3', url='../dep3', "
-        "revision='main', groups=(Group(name='test'),))",
+        "DEBUG   git-ws DUPLICATE Project(name='dep3', path='dep3', url='../dep3', "
+        "revision='main', groups=('test',))",
         "DEBUG   git-ws DUPLICATE Project(name='dep4', path='dep4', url='../dep4', revision='main')",
     ]
diff --git a/tests/test_cli_clone.py b/tests/test_cli_clone.py
new file mode 100644
index 0000000..ba88be1
--- /dev/null
+++ b/tests/test_cli_clone.py
@@ -0,0 +1,255 @@
+# Copyright 2022 c0fec0de
+#
+# This file is part of Git Workspace.
+#
+# Git Workspace is free software: you can redistribute it and/or modify it under
+# the terms of the GNU Lesser General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# Git Workspace is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with Git Workspace. If not, see <https://www.gnu.org/licenses/>.
+
+"""Clone Testing."""
+# pylint: disable=unused-import
+from .fixtures import repos
+from .util import chdir, check, cli
+
+
+def test_cli_clone(tmp_path, repos):
+    """Cloning via CLI."""
+    workspace = tmp_path / "main"
+
+    with chdir(tmp_path):
+        assert cli(["clone", str(repos / "main")]) == [
+            "===== main/main (MAIN 'main') =====",
+            f"Cloning '{tmp_path}/repos/main'.",
+            "Workspace initialized at 'main'. Please continue with:",
+            "",
+            "    git ws update",
+            "",
+            "",
+        ]
+
+        check(workspace, "main")
+        check(workspace, "dep1", exists=False)
+        check(workspace, "dep2", exists=False)
+        check(workspace, "dep3", exists=False)
+        check(workspace, "dep4", exists=False)
+        check(workspace, "dep5", exists=False)
+
+
+def test_cli_clone_path(tmp_path, repos):
+    """Cloning via CLI."""
+    workspace = tmp_path / "workspace"
+    workspace.mkdir()
+
+    with chdir(workspace):
+        assert cli(["clone", str(repos / "main"), "main2"]) == [
+            "===== main2 (MAIN 'main') =====",
+            f"Cloning '{tmp_path}/repos/main'.",
+            "Workspace initialized at '.'. Please continue with:",
+            "",
+            "    git ws update",
+            "",
+            "",
+        ]
+
+        check(workspace, "main", exists=False)
+        check(workspace, "main2", content="main")
+        check(workspace, "dep1", exists=False)
+        check(workspace, "dep2", exists=False)
+        check(workspace, "dep3", exists=False)
+        check(workspace, "dep4", exists=False)
+        check(workspace, "dep5", exists=False)
+
+        # Disable color here, to test normal error output
+        assert cli(("config", "set", "color_ui", "False")) == [""]
+
+        assert cli(["checkout"], tmp_path=tmp_path) == [
+            "===== main2 (MAIN 'main2') =====",
+            "===== dep1 ('dep1') =====",
+            "Cloning 'TMP/repos/dep1'.",
+            "git-ws WARNING Clone dep1 has no revision!",
+            "===== dep2 ('dep2', revision='1-feature') =====",
+            "Cloning 'TMP/repos/dep2'.",
+            "===== dep4 ('dep4', revision='main') =====",
+            "Cloning 'TMP/repos/dep4'.",
+            "",
+        ]
+
+        check(workspace, "main", exists=False)
+        check(workspace, "main2", content="main")
+        check(workspace, "dep1")
+        check(workspace, "dep2", content="dep2-feature")
+        check(workspace, "dep3", exists=False)
+        check(workspace, "dep4")
+        check(workspace, "dep5", exists=False)
+
+
+def test_cli_clone_not_empty(tmp_path, repos):
+    """Cloning via CLI not empty."""
+    workspace = tmp_path / "main"
+    workspace.mkdir()
+
+    with chdir(tmp_path):
+        (workspace / "file.txt").touch()
+        assert cli(["clone", str(repos / "main")], exit_code=1) == [
+            "Error: Workspace 'main' is not an empty directory.",
+            "",
+            "Choose an empty directory or use '--force'",
+            "",
+            "",
+        ]
+
+        check(workspace, "main", exists=False)
+        check(workspace, "dep1", exists=False)
+        check(workspace, "dep2", exists=False)
+        check(workspace, "dep3", exists=False)
+        check(workspace, "dep4", exists=False)
+        check(workspace, "dep5", exists=False)
+
+        assert cli(["clone", str(repos / "main"), "--force"], tmp_path=tmp_path) == [
+            "===== main/main (MAIN 'main') =====",
+            "Cloning 'TMP/repos/main'.",
+            "Workspace initialized at 'main'. Please continue with:",
+            "",
+            "    git ws update",
+            "",
+            "",
+        ]
+
+        check(workspace, "main")
+        check(workspace, "dep1", exists=False)
+        check(workspace, "dep2", exists=False)
+        check(workspace, "dep3", exists=False)
+        check(workspace, "dep4", exists=False)
+        check(workspace, "dep5", exists=False)
+
+
+def test_cli_clone_update(tmp_path, repos):
+    """Cloning via CLI."""
+    workspace = tmp_path / "main"
+
+    with chdir(tmp_path):
+        assert cli(["clone", str(repos / "main"), "--update"]) == [
+            "===== main/main (MAIN 'main') =====",
+            f"Cloning '{tmp_path}/repos/main'.",
+            "===== main/dep1 ('dep1') =====",
+            "git-ws WARNING Clone dep1 has no revision!",
+            f"Cloning '{tmp_path}/repos/dep1'.",
+            "===== main/dep2 ('dep2', revision='1-feature') =====",
+            f"Cloning '{tmp_path}/repos/dep2'.",
+            "===== main/dep4 ('dep4', revision='main') =====",
+            f"Cloning '{tmp_path}/repos/dep4'.",
+            "",
+        ]
+
+    check(workspace, "main")
+    check(workspace, "dep1")
+    check(workspace, "dep2", content="dep2-feature")
+    check(workspace, "dep3", exists=False)
+    check(workspace, "dep4")
+    check(workspace, "dep5", exists=False)
+
+
+def test_cli_clone_checkout(tmp_path, repos):
+    """Cloning via CLI and checkout."""
+    workspace = tmp_path / "main"
+
+    with chdir(tmp_path):
+        assert cli(["clone", str(repos / "main")]) == [
+            "===== main/main (MAIN 'main') =====",
+            f"Cloning '{tmp_path}/repos/main'.",
+            "Workspace initialized at 'main'. Please continue with:",
+            "",
+            "    git ws update",
+            "",
+            "",
+        ]
+
+        check(workspace, "main")
+        check(workspace, "dep1", exists=False)
+        check(workspace, "dep2", exists=False)
+        check(workspace, "dep3", exists=False)
+        check(workspace, "dep4", exists=False)
+        check(workspace, "dep5", exists=False)
+
+    with chdir(workspace / "main"):
+        assert cli(["checkout"], tmp_path=tmp_path) == [
+            "===== . (MAIN 'main') =====",
+            "===== ../dep1 ('dep1') =====",
+            "Cloning 'TMP/repos/dep1'.",
+            "git-ws WARNING Clone dep1 has no revision!",
+            "===== ../dep2 ('dep2', revision='1-feature') =====",
+            "Cloning 'TMP/repos/dep2'.",
+            "===== ../dep4 ('dep4', revision='main') =====",
+            "Cloning 'TMP/repos/dep4'.",
+            "",
+        ]
+
+        check(workspace, "main")
+        check(workspace, "dep1")
+        check(workspace, "dep2", content="dep2-feature")
+        check(workspace, "dep3", exists=False)
+        check(workspace, "dep4")
+        check(workspace, "dep5", exists=False)
+
+
+def test_cli_clone_groups(tmp_path, repos):
+    """Cloning via CLI with groups."""
+    workspace = tmp_path / "main"
+
+    with chdir(tmp_path):
+        assert cli(["clone", str(repos / "main"), "-G", "+test"]) == [
+            "===== main/main (MAIN 'main') =====",
+            "Cloning " f"'{tmp_path}/repos/main'.",
+            "Workspace initialized at 'main'. Please continue with:",
+            "",
+            "    git ws update",
+            "",
+            "",
+        ]
+
+        check(workspace, "main")
+        check(workspace, "dep1", exists=False)
+        check(workspace, "dep2", exists=False)
+        check(workspace, "dep3", exists=False)
+        check(workspace, "dep4", exists=False)
+        check(workspace, "dep5", exists=False)
+
+    assert (
+        (workspace / ".git-ws" / "config.toml").read_text()
+        == """\
+manifest_path = "git-ws.toml"
+group_filters = ["+test"]
+"""
+    )
+
+    with chdir(workspace):
+        assert cli(["update"]) == [
+            "===== main (MAIN 'main') =====",
+            "Pulling branch 'main'.",
+            "===== dep1 ('dep1') =====",
+            "git-ws WARNING Clone dep1 has no revision!",
+            f"Cloning '{tmp_path}/repos/dep1'.",
+            "===== dep2 ('dep2', revision='1-feature') =====",
+            f"Cloning '{tmp_path}/repos/dep2'.",
+            "===== dep3 ('dep3', groups='test') =====",
+            "git-ws WARNING Clone dep3 (groups='test') has no revision!",
+            f"Cloning '{tmp_path}/repos/dep3'.",
+            "===== dep4 ('dep4', revision='main') =====",
+            f"Cloning '{tmp_path}/repos/dep4'.",
+            "",
+        ]
+
+        check(workspace, "main")
+        check(workspace, "dep1")
+        check(workspace, "dep2", content="dep2-feature")
+        check(workspace, "dep3")
+        check(workspace, "dep4")
+        check(workspace, "dep5", exists=False)
diff --git a/tests/test_cli_cornercases.py b/tests/test_cli_cornercases.py
index 535591c..7e371d1 100644
--- a/tests/test_cli_cornercases.py
+++ b/tests/test_cli_cornercases.py
@@ -22,18 +22,7 @@
 
 # pylint: disable=unused-import
 from .fixtures import git_repo
-from .util import chdir, format_logs
-
-
-def check(workspace, name, content=None, exists=True):
-    """Check."""
-    file_path = workspace / name / "data.txt"
-    content = content or name
-    if exists:
-        assert file_path.exists()
-        assert file_path.read_text() == f"{content}"
-    else:
-        assert not file_path.exists()
+from .util import chdir, check, format_logs
 
 
 @fixture
@@ -81,10 +70,9 @@ def repos_deptop(tmp_path):
 
 def test_deptop(tmp_path, repos_deptop, caplog):
     """Initialized :any:`GitWS` on `repos_deptop`."""
-    workspace = tmp_path / "workspace"
-    workspace.mkdir()
+    workspace = tmp_path / "top"
 
-    with chdir(workspace):
+    with chdir(tmp_path):
         gws = GitWS.clone(str(repos_deptop / "top"))
 
         check(workspace, "top")
@@ -100,45 +88,46 @@ def test_deptop(tmp_path, repos_deptop, caplog):
         check(workspace, "dep3")
 
     assert format_logs(caplog, tmp_path) == [
-        "INFO    git-ws Git('top').clone('TMP/repos/top', revision=None)",
-        "DEBUG   git-ws run(('git', 'clone', '--', 'TMP/repos/top', 'top'), cwd='None') OK stdout=None stderr=None",
-        "DEBUG   git-ws GitWS.create('.', 'top', 'git-ws.toml', groups=None)",
-        "INFO    git-ws Initialized . Info(main_path=PosixPath('top')) "
-        "AppConfigData(manifest_path='git-ws.toml', color_ui=True, groups=None)",
-        "DEBUG   git-ws run(['git', 'remote', 'get-url', 'origin'], cwd='top') OK "
+        "INFO    git-ws Git('top/top').clone('TMP/repos/top', revision=None)",
+        "DEBUG   git-ws run(('git', 'clone', '--', 'TMP/repos/top', 'top/top'), "
+        "cwd='None') OK stdout=None stderr=None",
+        "DEBUG   git-ws GitWS.create('TMP/top', 'TMP/top/top', manifest_path='None', group-filters=None)",
+        "INFO    git-ws Workspace path=TMP/top main=top",
+        "INFO    git-ws AppConfigData(manifest_path='git-ws.toml', color_ui=True, group_filters=None)",
+        "DEBUG   git-ws run(['git', 'remote', 'get-url', 'origin'], cwd='top/top') OK "
         "stdout=b'TMP/repos/top\\n' stderr=b''",
-        "INFO    git-ws Git('top').get_url() = 'TMP/repos/top'",
+        "INFO    git-ws Git('top/top').get_url() = 'TMP/repos/top'",
         "DEBUG   git-ws ManifestSpec(dependencies=(ProjectSpec(name='dep1', "
         "url='../dep1'), ProjectSpec(name='dep2', url='../dep2')))",
         "DEBUG   git-ws Project(name='dep1', path='dep1', url='TMP/repos/dep1')",
         "WARNING git-ws Clone dep1 has no revision!",
-        "INFO    git-ws Git('dep1').clone('TMP/repos/dep1', revision=None)",
-        "DEBUG   git-ws run(('git', 'clone', '--', 'TMP/repos/dep1', 'dep1'), "
+        "INFO    git-ws Git('top/dep1').clone('TMP/repos/dep1', revision=None)",
+        "DEBUG   git-ws run(('git', 'clone', '--', 'TMP/repos/dep1', 'top/dep1'), "
         "cwd='None') OK stdout=None stderr=None",
         "DEBUG   git-ws Project(name='dep2', path='dep2', url='TMP/repos/dep2')",
         "WARNING git-ws Clone dep2 has no revision!",
-        "INFO    git-ws Git('dep2').clone('TMP/repos/dep2', revision=None)",
-        "DEBUG   git-ws run(('git', 'clone', '--', 'TMP/repos/dep2', 'dep2'), "
+        "INFO    git-ws Git('top/dep2').clone('TMP/repos/dep2', revision=None)",
+        "DEBUG   git-ws run(('git', 'clone', '--', 'TMP/repos/dep2', 'top/dep2'), "
         "cwd='None') OK stdout=None stderr=None",
-        "DEBUG   git-ws run(['git', 'remote', 'get-url', 'origin'], cwd='dep1') OK "
-        "stdout=b'TMP/repos/dep1\\n' stderr=b''",
-        "INFO    git-ws Git('dep1').get_url() = 'TMP/repos/dep1'",
+        "DEBUG   git-ws run(['git', 'remote', 'get-url', 'origin'], cwd='top/dep1') "
+        "OK stdout=b'TMP/repos/dep1\\n' stderr=b''",
+        "INFO    git-ws Git('top/dep1').get_url() = 'TMP/repos/dep1'",
         "DEBUG   git-ws ManifestSpec(dependencies=(ProjectSpec(name='dep3', "
         "url='../dep3', revision='main'), ProjectSpec(name='top', url='../top')))",
         "DEBUG   git-ws Project(name='dep3', path='dep3', url='TMP/repos/dep3', revision='main')",
-        "INFO    git-ws Git('dep3').clone('TMP/repos/dep3', revision='main')",
+        "INFO    git-ws Git('top/dep3').clone('TMP/repos/dep3', revision='main')",
         "DEBUG   git-ws run(('git', 'clone', '--no-checkout', '--', 'TMP/repos/dep3', "
-        "'dep3'), cwd='None') OK stdout=None stderr=None",
-        "DEBUG   git-ws run(['git', 'checkout', 'main'], cwd='dep3') OK stdout=None stderr=None",
+        "'top/dep3'), cwd='None') OK stdout=None stderr=None",
+        "DEBUG   git-ws run(['git', 'checkout', 'main'], cwd='top/dep3') OK stdout=None stderr=None",
         "DEBUG   git-ws DUPLICATE Project(name='top', path='top', url='TMP/repos/top')",
-        "DEBUG   git-ws run(['git', 'remote', 'get-url', 'origin'], cwd='dep3') OK "
-        "stdout=b'TMP/repos/dep3\\n' stderr=b''",
-        "INFO    git-ws Git('dep3').get_url() = 'TMP/repos/dep3'",
+        "DEBUG   git-ws run(['git', 'remote', 'get-url', 'origin'], cwd='top/dep3') "
+        "OK stdout=b'TMP/repos/dep3\\n' stderr=b''",
+        "INFO    git-ws Git('top/dep3').get_url() = 'TMP/repos/dep3'",
         "DEBUG   git-ws ManifestSpec(dependencies=(ProjectSpec(name='top'),))",
         "DEBUG   git-ws DUPLICATE Project(name='top', path='top', url='TMP/repos/top')",
-        "DEBUG   git-ws run(['git', 'remote', 'get-url', 'origin'], cwd='dep2') OK "
-        "stdout=b'TMP/repos/dep2\\n' stderr=b''",
-        "INFO    git-ws Git('dep2').get_url() = 'TMP/repos/dep2'",
+        "DEBUG   git-ws run(['git', 'remote', 'get-url', 'origin'], cwd='top/dep2') "
+        "OK stdout=b'TMP/repos/dep2\\n' stderr=b''",
+        "INFO    git-ws Git('top/dep2').get_url() = 'TMP/repos/dep2'",
         "DEBUG   git-ws ManifestSpec(dependencies=(ProjectSpec(name='dep3', url='../dep3', revision='main'),))",
         "DEBUG   git-ws DUPLICATE Project(name='dep3', path='dep3', url='TMP/repos/dep3', revision='main')",
     ]
diff --git a/tests/test_cli_info.py b/tests/test_cli_info.py
index bfe7e3b..291587c 100644
--- a/tests/test_cli_info.py
+++ b/tests/test_cli_info.py
@@ -27,36 +27,33 @@
 @fixture
 def gws(tmp_path, repos):
     """Initialized :any:`GitWS` on `repos`."""
-    workspace = tmp_path / "workspace"
-    workspace.mkdir()
-
-    with chdir(workspace):
+    with chdir(tmp_path):
         gws = GitWS.clone(str(repos / "main"))
         gws.update()
-
+    with chdir(tmp_path / "main"):
         yield gws
 
 
 def test_main_path(tmp_path, gws):
     """Main Path."""
     # pylint: disable=unused-argument
-    assert cli(["info", "main-path"], tmp_path=tmp_path) == ["TMP/workspace/main", ""]
+    assert cli(["info", "main-path"], tmp_path=tmp_path) == ["TMP/main/main", ""]
 
 
 def test_workspace_path(tmp_path, gws):
     """Workspace Path."""
     # pylint: disable=unused-argument
-    assert cli(["info", "workspace-path"], tmp_path=tmp_path) == ["TMP/workspace", ""]
+    assert cli(["info", "workspace-path"], tmp_path=tmp_path) == ["TMP/main", ""]
 
 
 def test_project_paths(tmp_path, gws):
     """Project Paths."""
     # pylint: disable=unused-argument
     assert cli(["info", "project-paths"], tmp_path=tmp_path) == [
-        "TMP/workspace/main",
-        "TMP/workspace/dep1",
-        "TMP/workspace/dep2",
-        "TMP/workspace/dep4",
+        "TMP/main/main",
+        "TMP/main/dep1",
+        "TMP/main/dep2",
+        "TMP/main/dep4",
         "",
     ]
 
@@ -68,9 +65,10 @@ def test_dep_tree(tmp_path, gws):
         "main",
         "├── dep1",
         "│   └── dep4 (revision='main')",
-        "└── dep2 (revision='1-feature')",
-        "    ├── dep3 (revision='main', groups='test')",
-        "    └── dep4 (revision='main')*",
+        "├── dep2 (revision='1-feature')",
+        "│   ├── dep3 (revision='main', groups='test')",
+        "│   └── dep4 (revision='main')*",
+        "└── dep3 (groups='test')*",
         "",
     ]
 
@@ -86,8 +84,10 @@ def test_dep_tree_dot(tmp_path, gws):
         '    "dep2";',
         '    "dep3";',
         '    "dep4";',
+        '    "dep3";',
         '    "main" -> "dep1";',
         '    "main" -> "dep2" [label="1-feature"];',
+        '    "main" -> "dep3" [style="dashed";label="(test)"];',
         '    "dep1" -> "dep4" [label="main"];',
         '    "dep2" -> "dep3" [label="main (test)"];',
         '    "dep2" -> "dep4" [style="dashed";label="main"];',
diff --git a/tests/test_cli_init.py b/tests/test_cli_init.py
new file mode 100644
index 0000000..77aa5d8
--- /dev/null
+++ b/tests/test_cli_init.py
@@ -0,0 +1,195 @@
+# Copyright 2022 c0fec0de
+#
+# This file is part of Git Workspace.
+#
+# Git Workspace is free software: you can redistribute it and/or modify it under
+# the terms of the GNU Lesser General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# Git Workspace is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with Git Workspace. If not, see <https://www.gnu.org/licenses/>.
+
+"""Initialization Tests."""
+from .common import MANIFEST_DEFAULT
+from .util import chdir, cli, run
+
+
+def test_cli_nogit(tmp_path):
+    """Init without GIT repo."""
+    main_path = tmp_path / "main"
+    main_path.mkdir(parents=True)
+    with chdir(main_path):
+        assert cli(["init"], exit_code=1) == [
+            "Error: git clone has not been found or initialized yet. Change to your existing git clone or try:",
+            "",
+            "    git init",
+            "",
+            "or:",
+            "",
+            "    git clone",
+            "",
+            "",
+        ]
+
+
+def test_cli_git(tmp_path):
+    """Init with GIT repo."""
+    main_path = tmp_path / "main"
+    main_path.mkdir(parents=True)
+    with chdir(main_path):
+        run(("git", "init"), check=True)
+        assert (main_path / ".git").exists()
+        assert not (tmp_path / ".git-ws").exists()
+
+        assert cli(["init"], exit_code=1) == [
+            "===== . (MAIN 'main') =====",
+            "Error: Manifest has not been found at 'git-ws.toml'. Try:",
+            "",
+            "    git ws manifest create --manifest='git-ws.toml'",
+            "",
+            "",
+        ]
+
+        assert not (tmp_path / ".git-ws").exists()
+
+        assert cli(["manifest", "create"]) == ["Manifest 'git-ws.toml' created.", ""]
+
+        manifest_path = main_path / "git-ws.toml"
+        assert manifest_path.read_text() == MANIFEST_DEFAULT
+
+        assert cli(["init"]) == [
+            "===== . (MAIN 'main') =====",
+            "Workspace initialized at '..'.",
+            "Please continue with:",
+            "",
+            "    git ws update",
+            "",
+            "",
+        ]
+
+        assert cli(["init"], tmp_path=tmp_path, exit_code=1) == [
+            "Error: git workspace has already been initialized at 'TMP' with main repo at 'main'.",
+            "",
+        ]
+
+
+def test_cli_abs_manifest(tmp_path):
+    """Init with GIT repo."""
+    main_path = tmp_path / "main"
+    main_path.mkdir(parents=True)
+    with chdir(main_path):
+        run(("git", "init"), check=True)
+        assert (main_path / ".git").exists()
+        assert not (tmp_path / ".git-ws").exists()
+        assert cli(["manifest", "create"]) == ["Manifest 'git-ws.toml' created.", ""]
+
+        assert cli(["init", "-M", str(main_path / "git-ws.toml")]) == [
+            "===== . (MAIN 'main') =====",
+            "Workspace initialized at '..'.",
+            "Please continue with:",
+            "",
+            "    git ws update",
+            "",
+            "",
+        ]
+
+        assert cli(["init"], tmp_path=tmp_path, exit_code=1) == [
+            "Error: git workspace has already been initialized at 'TMP' with main repo at 'main'.",
+            "",
+        ]
+
+
+def test_cli_git_path(tmp_path):
+    """Init with GIT repo."""
+    main_path = tmp_path / "main"
+    main_path.mkdir(parents=True)
+    with chdir(main_path):
+        run(("git", "init"), check=True)
+        assert (main_path / ".git").exists()
+        assert cli(["manifest", "create"]) == ["Manifest 'git-ws.toml' created.", ""]
+
+    with chdir(tmp_path):
+        assert cli(["init", "main", "add"], exit_code=1) == ["Error: more than one PATH specified", ""]
+
+        assert cli(["init", "main"]) == [
+            "===== main (MAIN 'main') =====",
+            "Workspace initialized at '.'.",
+            "Please continue with:",
+            "",
+            "    git ws update",
+            "",
+            "",
+        ]
+
+        assert cli(["checkout"]) == ["===== main (MAIN 'main') =====", ""]
+
+    assert (
+        (tmp_path / ".git-ws" / "info.toml").read_text()
+        == """\
+# Git Workspace System File. DO NOT EDIT.
+
+main_path = "main"
+"""
+    )
+    assert (
+        (tmp_path / ".git-ws" / "config.toml").read_text()
+        == """\
+manifest_path = "git-ws.toml"
+"""
+    )
+
+
+def test_cli_init_exists(tmp_path):
+    """Init with GIT repo."""
+    main_path = tmp_path / "main"
+    main_path.mkdir(parents=True)
+    with chdir(main_path):
+        run(("git", "init"), check=True)
+        assert (main_path / ".git").exists()
+        assert cli(["manifest", "create"]) == ["Manifest 'git-ws.toml' created.", ""]
+
+        (tmp_path / "something").touch()
+
+        assert cli(["init"], tmp_path=tmp_path, exit_code=1) == [
+            "Error: Workspace '..' is not an empty directory.",
+            "",
+            "Choose an empty directory or use '--force'",
+            "",
+            "",
+        ]
+
+        assert not (tmp_path / ".git-ws").exists()
+        assert cli(["init", "--force"], tmp_path=tmp_path) == [
+            "===== . (MAIN 'main') =====",
+            "Workspace initialized at '..'.",
+            "Please continue with:",
+            "",
+            "    git ws update",
+            "",
+            "",
+        ]
+
+
+def test_cli_git_update(tmp_path):
+    """Init with GIT repo."""
+    main_path = tmp_path / "main"
+    main_path.mkdir(parents=True)
+    with chdir(main_path):
+        run(("git", "init"), check=True)
+        assert (main_path / ".git").exists()
+
+        assert cli(["manifest", "create"]) == ["Manifest 'git-ws.toml' created.", ""]
+
+        manifest_path = main_path / "git-ws.toml"
+        assert manifest_path.read_text() == MANIFEST_DEFAULT
+
+        assert cli(["init", "--update"], tmp_path=tmp_path) == [
+            "===== . (MAIN 'main') =====",
+            "Workspace initialized at '..'.",
+            "",
+        ]
diff --git a/tests/test_cli_manifest.py b/tests/test_cli_manifest.py
index d7824a9..8d29fa6 100644
--- a/tests/test_cli_manifest.py
+++ b/tests/test_cli_manifest.py
@@ -27,13 +27,13 @@
 @fixture
 def gws(tmp_path, repos):
     """Initialized :any:`GitWS` on `repos`."""
-    workspace = tmp_path / "workspace"
-    workspace.mkdir()
+    workspace = tmp_path / "main"
 
-    with chdir(workspace):
+    with chdir(tmp_path):
         gws = GitWS.clone(str(repos / "main"))
         gws.update()
 
+    with chdir(workspace):
         yield gws
 
 
@@ -43,7 +43,7 @@ def test_validate(tmp_path, gws):
 
     assert cli(["manifest", "validate"]) == [""]
 
-    manifest_path = tmp_path / "workspace" / "main" / "git-ws.toml"
+    manifest_path = tmp_path / "main" / "main" / "git-ws.toml"
     assert manifest_path.write_text(
         "\n".join(
             [
@@ -71,151 +71,95 @@ def test_freeze(tmp_path, gws):
     sha1 = get_sha(gws.path / "dep1")
     sha2 = get_sha(gws.path / "dep2")
     sha4 = get_sha(gws.path / "dep4")
-    lines = [
+    default = [
         'version = "1.0"',
         "##",
-        "## Welcome to Git Workspace's Manifest. It actually contains 4 parts:",
-        "##",
-        "## * Remotes",
-        "## * Groups",
-        "## * Defaults",
-        "## * Dependencies",
+        "## Git Workspace's Manifest. Please see the documentation at:",
         "##",
-        "## =========",
-        "##  Remotes",
-        "## =========",
-        "##",
-        "## Remotes just refer to a directory with repositories.",
-        "##",
-        "## We support relative paths for dependencies. So, if your dependencies are next",
-        "## to your repository, you might NOT need any remote.",
-        "## In other terms: You only need remotes if your dependencies are located on",
-        "## OTHER servers than your server with this manifest.",
-        "##",
-        "## Remotes have two attributes:",
-        "## * name: Required. String.",
-        "##         Name of the remote. Any valid string. Must be unique within your",
-        "##         manifest.",
-        "## * url-base: Required. String.",
-        "##             URL Prefix. The project 'name' or 'sub-url' will be appended",
-        "##             later-on.",
+        "## https://git-ws.readthedocs.io/en/latest/manual/manifest.html",
         "##",
+        "",
+        "",
+        '# group-filters = ["+test", "-doc", "+feature@path"]',
+        'group-filters = ["-test"]',
+        "",
+        "",
         "# [[remotes]]",
         '# name = "myremote"',
         '# url-base = "https://github.com/myuser"',
         "",
         "",
-        "## =========",
-        "##  Groups",
-        "## =========",
-        "##",
-        "## Groups structure dependencies.",
-        "##",
-        "## Groups are optional by default.",
-        "## If a dependency belongs to a group, it becomes optional likewise.",
-        "## Groups can be later on selected/deselected by '+group' or '-group'.",
-        "## An optional group can be selected by '+group',",
-        "## a non-optional group can be deselected by '-group'.",
-        "## Deselection has higher priority than selection.",
-        "##",
-        "## Dependencies can refer to non-existing groups. You do NOT need to specify",
-        "## all used groups.",
-        "##",
-        "## Groups have two attributes:",
-        "## * name: Required. String.",
-        "##         Name of the group. Any valid string. Must be unique within your",
-        "##         manifest.",
-        "## * optional: Optional. Bool. Default is True.",
-        "##             Specifies if the group is optional. Meaning it must be selected",
-        "##             explicitly. Otherwise the dependency is not added by default.",
-        "##",
-        "## The following lines set a group as non-optional.",
-        "# [[groups]]",
-        '# name = "test"',
-        "# optional = false",
+        "[defaults]",
+        '# remote = "myserver"',
+        '# revision = "main"',
+        '# groups = ["+test"]',
+        '# with_groups = ["doc"]',
+        "",
+        "",
+        "## A full flavored dependency using a 'remote':",
+        "# [[dependencies]]",
+        '# name = "myname"',
+        '# remote = "remote"',
+        '# sub-url = "my.git"',
+        '# revision = "main"',
+        '# path = "mydir"',
+        '# groups = ["group"]',
+        "",
+        "## A full flavored dependency using a 'url':",
+        "# [[dependencies]]",
+        '# name = "myname"',
+        '# url = "https://github.com/myuser/my.git"',
+        '# revision = "main"',
+        '# path = "mydir"',
+        '# groups = ["group"]',
+        "",
+        "## A minimal dependency:",
+        "# [[dependencies]]",
+        '# name = "my"',
+        "[[dependencies]]",
+        'name = "dep1"',
+        'url = "../dep1"',
+        f'revision = "{sha1}"',
+        'path = "dep1"',
+        "",
+        "[[dependencies]]",
+        'name = "dep2"',
+        'url = "../dep2"',
+        f'revision = "{sha2}"',
+        'path = "dep2"',
         "",
+        "[[dependencies]]",
+        'name = "dep4"',
+        'url = "../dep4"',
+        f'revision = "{sha4}"',
+        'path = "dep4"',
         "",
-        "## ==========",
-        "##  Defaults",
-        "## ==========",
+    ]
+    withtest = [
+        'version = "1.0"',
         "##",
-        "## The 'defaults' section specifies default values for dependencies.",
+        "## Git Workspace's Manifest. Please see the documentation at:",
         "##",
-        "## * remote: Optional. String.",
-        "##           Remote used as default.",
-        "##           The 'remote' MUST be defined in the 'remotes' section above!",
-        "## * revision: Optional. String.",
-        "##             Revision used as default. Tag or Branch.",
+        "## https://git-ws.readthedocs.io/en/latest/manual/manifest.html",
         "##",
-        "## NOTE: It is recommended to specify a default revision (i.e. 'main').",
-        "##       If a dependency misses 'revision', GitWS will not take care about",
-        "##       revision handling. This may lead to strange side-effects. You",
-        "##       have been warned.",
+        "",
+        "",
+        '# group-filters = ["+test", "-doc", "+feature@path"]',
+        'group-filters = ["-test"]',
+        "",
+        "",
+        "# [[remotes]]",
+        '# name = "myremote"',
+        '# url-base = "https://github.com/myuser"',
+        "",
         "",
         "[defaults]",
         '# remote = "myserver"',
         '# revision = "main"',
+        '# groups = ["+test"]',
+        '# with_groups = ["doc"]',
         "",
         "",
-        "## ==============",
-        "##  Dependencies",
-        "## ==============",
-        "##",
-        "## The 'dependencies' section specifies all your git clones you need for your",
-        "## project to operate.",
-        "##",
-        "## A dependency has the following attributes:",
-        "## * name: Required. String.",
-        "##         Just name your dependency. It is recommended to choose a",
-        "##         unique name, but not a must.",
-        "## * remote: Optional. String. Restricted (see RESTRICTIONS below).",
-        "##           Remote Alias.",
-        "##           The 'remote' MUST be defined in the 'remotes' section above!",
-        "##           The 'remote' can also be specified in the 'defaults' section.",
-        "## * sub-url: Optional. String. Default: '../{name}[.git]' (see NOTE1 below).",
-        "##            Relative URL to 'url-base' of your specified 'remote'",
-        "##            OR",
-        "##            Relative URL to the URL of the repository containing this",
-        "##            manifest.",
-        "## * url: Optional. String. Restricted (see RESTRICTIONS below).",
-        "##        Absolute URL to the dependent repository.",
-        "## * revision: Optional. String.",
-        "##             Revision to be checked out.",
-        "##             If this attribute is left blank, GitWS does NOT manage the",
-        "##             dependency revision (see NOTE2 below)!",
-        "##             The 'revision' can also be specified in the 'defaults' section.",
-        "## * path: Optional. String. Default is '{name}'.",
-        "##         Project Filesystem Path. Relative to Workspace Root Directory.",
-        "##         The dependency 'name' is used as default for 'path'.",
-        "##         The 'path' MUST be unique within your manifest.",
-        "## * manifest_path: Optional. String. Default: 'git-ws.toml'.",
-        "##                   Path to manifest.",
-        "##                   Relative to 'path'.",
-        "##                   Avoid changing it! It is just additional effort.",
-        "## * groups: Optional. List of Strings.",
-        "##           Dependency Groups.",
-        "##           Dependencies can be categorized into groups.",
-        "##           Groups are optional by default. See 'groups' section above.",
-        "##",
-        "## NOTE1: 'sub-url' is '../{name}[.git]' by default. Meaning if the dependency",
-        "##        is next to your repository containing this manifest, the dependency",
-        "##        is automatically found.",
-        "##        The '.git' suffix is appended if the repository containing this",
-        "##        manifest uses a '.git' suffix.",
-        "##",
-        "## NOTE2: It is recommended to specify a revision (i.e. 'main') either",
-        "##        explicitly or via the 'default' section.",
-        "##        Without a 'revision' GitWS will not take care about revision",
-        "##        handling. This may lead to strange side-effects.",
-        "##        You have been warned.",
-        "##",
-        "## RESTRICTIONS:",
-        "##",
-        "## * `remote` and `url` are mutually exclusive.",
-        "## * `url` and `sub-url` are likewise mutually exclusive",
-        "## * `sub-url` requires a `remote`.",
-        "##",
-        "##",
         "## A full flavored dependency using a 'remote':",
         "# [[dependencies]]",
         '# name = "myname"',
@@ -249,11 +193,19 @@ def test_freeze(tmp_path, gws):
         'path = "dep2"',
         "",
         "[[dependencies]]",
+        'name = "dep3"',
+        'url = "../dep3"',
+        'revision = "v1.0"',
+        'path = "dep3"',
+        'groups = ["test"]',
+        "",
+        "[[dependencies]]",
         'name = "dep4"',
         'url = "../dep4"',
         f'revision = "{sha4}"',
         'path = "dep4"',
         "",
+        "",
     ]
 
     # STDOUT
@@ -266,30 +218,21 @@ def test_freeze(tmp_path, gws):
     ]
 
     assert cli(["update", "-G", "+test"], tmp_path=tmp_path) == [
-        "Groups: '+test'",
-        "===== main (MAIN) =====",
+        "===== main (MAIN 'main') =====",
         "Pulling branch 'main'.",
-        "===== dep1 =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
         "Pulling branch 'main'.",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "Pulling branch '1-feature'.",
-        "===== dep4 (revision='main') =====",
-        "Pulling branch 'main'.",
-        "===== dep3 (revision='main', groups='test') =====",
+        "===== dep3 ('dep3', groups='test') =====",
+        "git-ws WARNING Clone dep3 (groups='test') has no revision!",
         "Cloning 'TMP/repos/dep3'.",
+        "===== dep4 ('dep4', revision='main') =====",
+        "Pulling branch 'main'.",
         "",
     ]
-    assert cli(["manifest", "freeze", "-G", "+test"]) == lines + [
-        "[[dependencies]]",
-        'name = "dep3"',
-        'url = "../dep3"',
-        'revision = "v1.0"',
-        'path = "dep3"',
-        'groups = ["test"]',
-        "",
-        "",
-    ]
+    assert cli(["manifest", "freeze", "-G", "+test"]) == withtest
 
     # FILE
     output_path = tmp_path / "manifest.toml"
@@ -297,34 +240,34 @@ def test_freeze(tmp_path, gws):
         "",
     ]
 
-    assert output_path.read_text().split("\n") == lines
+    assert output_path.read_text().split("\n") == default
 
     assert cli(["update", "--manifest", str(output_path)]) == [
-        "===== main (MAIN) =====",
+        "===== main (MAIN 'main') =====",
         "Pulling branch 'main'.",
-        f"===== dep1 (revision={sha1!r}) =====",
+        f"===== dep1 ('dep1', revision={sha1!r}) =====",
         "Fetching.",
         f"Checking out {sha1!r} (previously 'main').",
-        f"===== dep2 (revision={sha2!r}) =====",
+        f"===== dep2 ('dep2', revision={sha2!r}) =====",
         "Fetching.",
         f"Checking out {sha2!r} (previously '1-feature').",
-        f"===== dep4 (revision={sha4!r}) =====",
+        f"===== dep4 ('dep4', revision={sha4!r}) =====",
         "Fetching.",
         f"Checking out {sha4!r} (previously 'main').",
         "",
     ]
 
     # STDOUT again
-    assert cli(["manifest", "freeze"]) == lines + [""]
+    assert cli(["manifest", "freeze"]) == default + [""]
 
     assert cli(["update", "--manifest", str(output_path)]) == [
-        "===== main (MAIN) =====",
+        "===== main (MAIN 'main') =====",
         "Pulling branch 'main'.",
-        f"===== dep1 (revision={sha1!r}) =====",
+        f"===== dep1 ('dep1', revision={sha1!r}) =====",
         "Nothing to do.",
-        f"===== dep2 (revision={sha2!r}) =====",
+        f"===== dep2 ('dep2', revision={sha2!r}) =====",
         "Nothing to do.",
-        f"===== dep4 (revision={sha4!r}) =====",
+        f"===== dep4 ('dep4', revision={sha4!r}) =====",
         "Nothing to do.",
         "",
     ]
@@ -337,148 +280,28 @@ def test_resolve(tmp_path, gws):
     lines = [
         'version = "1.0"',
         "##",
-        "## Welcome to Git Workspace's Manifest. It actually contains 4 parts:",
-        "##",
-        "## * Remotes",
-        "## * Groups",
-        "## * Defaults",
-        "## * Dependencies",
-        "##",
-        "## =========",
-        "##  Remotes",
-        "## =========",
-        "##",
-        "## Remotes just refer to a directory with repositories.",
-        "##",
-        "## We support relative paths for dependencies. So, if your dependencies are next",
-        "## to your repository, you might NOT need any remote.",
-        "## In other terms: You only need remotes if your dependencies are located on",
-        "## OTHER servers than your server with this manifest.",
+        "## Git Workspace's Manifest. Please see the documentation at:",
         "##",
-        "## Remotes have two attributes:",
-        "## * name: Required. String.",
-        "##         Name of the remote. Any valid string. Must be unique within your",
-        "##         manifest.",
-        "## * url-base: Required. String.",
-        "##             URL Prefix. The project 'name' or 'sub-url' will be appended",
-        "##             later-on.",
+        "## https://git-ws.readthedocs.io/en/latest/manual/manifest.html",
         "##",
-        "# [[remotes]]",
-        '# name = "myremote"',
-        '# url-base = "https://github.com/myuser"',
         "",
         "",
-        "## =========",
-        "##  Groups",
-        "## =========",
-        "##",
-        "## Groups structure dependencies.",
-        "##",
-        "## Groups are optional by default.",
-        "## If a dependency belongs to a group, it becomes optional likewise.",
-        "## Groups can be later on selected/deselected by '+group' or '-group'.",
-        "## An optional group can be selected by '+group',",
-        "## a non-optional group can be deselected by '-group'.",
-        "## Deselection has higher priority than selection.",
-        "##",
-        "## Dependencies can refer to non-existing groups. You do NOT need to specify",
-        "## all used groups.",
-        "##",
-        "## Groups have two attributes:",
-        "## * name: Required. String.",
-        "##         Name of the group. Any valid string. Must be unique within your",
-        "##         manifest.",
-        "## * optional: Optional. Bool. Default is True.",
-        "##             Specifies if the group is optional. Meaning it must be selected",
-        "##             explicitly. Otherwise the dependency is not added by default.",
-        "##",
-        "## The following lines set a group as non-optional.",
-        "# [[groups]]",
-        '# name = "test"',
-        "# optional = false",
+        '# group-filters = ["+test", "-doc", "+feature@path"]',
+        'group-filters = ["-test"]',
         "",
         "",
-        "## ==========",
-        "##  Defaults",
-        "## ==========",
-        "##",
-        "## The 'defaults' section specifies default values for dependencies.",
-        "##",
-        "## * remote: Optional. String.",
-        "##           Remote used as default.",
-        "##           The 'remote' MUST be defined in the 'remotes' section above!",
-        "## * revision: Optional. String.",
-        "##             Revision used as default. Tag or Branch.",
-        "##",
-        "## NOTE: It is recommended to specify a default revision (i.e. 'main').",
-        "##       If a dependency misses 'revision', GitWS will not take care about",
-        "##       revision handling. This may lead to strange side-effects. You",
-        "##       have been warned.",
+        "# [[remotes]]",
+        '# name = "myremote"',
+        '# url-base = "https://github.com/myuser"',
+        "",
         "",
         "[defaults]",
         '# remote = "myserver"',
         '# revision = "main"',
+        '# groups = ["+test"]',
+        '# with_groups = ["doc"]',
         "",
         "",
-        "## ==============",
-        "##  Dependencies",
-        "## ==============",
-        "##",
-        "## The 'dependencies' section specifies all your git clones you need for your",
-        "## project to operate.",
-        "##",
-        "## A dependency has the following attributes:",
-        "## * name: Required. String.",
-        "##         Just name your dependency. It is recommended to choose a",
-        "##         unique name, but not a must.",
-        "## * remote: Optional. String. Restricted (see RESTRICTIONS below).",
-        "##           Remote Alias.",
-        "##           The 'remote' MUST be defined in the 'remotes' section above!",
-        "##           The 'remote' can also be specified in the 'defaults' section.",
-        "## * sub-url: Optional. String. Default: '../{name}[.git]' (see NOTE1 below).",
-        "##            Relative URL to 'url-base' of your specified 'remote'",
-        "##            OR",
-        "##            Relative URL to the URL of the repository containing this",
-        "##            manifest.",
-        "## * url: Optional. String. Restricted (see RESTRICTIONS below).",
-        "##        Absolute URL to the dependent repository.",
-        "## * revision: Optional. String.",
-        "##             Revision to be checked out.",
-        "##             If this attribute is left blank, GitWS does NOT manage the",
-        "##             dependency revision (see NOTE2 below)!",
-        "##             The 'revision' can also be specified in the 'defaults' section.",
-        "## * path: Optional. String. Default is '{name}'.",
-        "##         Project Filesystem Path. Relative to Workspace Root Directory.",
-        "##         The dependency 'name' is used as default for 'path'.",
-        "##         The 'path' MUST be unique within your manifest.",
-        "## * manifest_path: Optional. String. Default: 'git-ws.toml'.",
-        "##                   Path to manifest.",
-        "##                   Relative to 'path'.",
-        "##                   Avoid changing it! It is just additional effort.",
-        "## * groups: Optional. List of Strings.",
-        "##           Dependency Groups.",
-        "##           Dependencies can be categorized into groups.",
-        "##           Groups are optional by default. See 'groups' section above.",
-        "##",
-        "## NOTE1: 'sub-url' is '../{name}[.git]' by default. Meaning if the dependency",
-        "##        is next to your repository containing this manifest, the dependency",
-        "##        is automatically found.",
-        "##        The '.git' suffix is appended if the repository containing this",
-        "##        manifest uses a '.git' suffix.",
-        "##",
-        "## NOTE2: It is recommended to specify a revision (i.e. 'main') either",
-        "##        explicitly or via the 'default' section.",
-        "##        Without a 'revision' GitWS will not take care about revision",
-        "##        handling. This may lead to strange side-effects.",
-        "##        You have been warned.",
-        "##",
-        "## RESTRICTIONS:",
-        "##",
-        "## * `remote` and `url` are mutually exclusive.",
-        "## * `url` and `sub-url` are likewise mutually exclusive",
-        "## * `sub-url` requires a `remote`.",
-        "##",
-        "##",
         "## A full flavored dependency using a 'remote':",
         "# [[dependencies]]",
         '# name = "myname"',
@@ -532,16 +355,16 @@ def test_resolve(tmp_path, gws):
 def test_path(tmp_path, gws):
     """Manifest Path."""
     # pylint: disable=unused-argument
-    assert cli(["manifest", "path"], tmp_path=tmp_path) == ["TMP/workspace/main/git-ws.toml", ""]
+    assert cli(["manifest", "path"], tmp_path=tmp_path) == ["TMP/main/main/git-ws.toml", ""]
 
 
 def test_paths(tmp_path, gws):
     """Manifest Paths."""
     # pylint: disable=unused-argument
     assert cli(["manifest", "paths"], tmp_path=tmp_path) == [
-        "TMP/workspace/main/git-ws.toml",
-        "TMP/workspace/dep1/git-ws.toml",
-        "TMP/workspace/dep2/git-ws.toml",
+        "TMP/main/main/git-ws.toml",
+        "TMP/main/dep1/git-ws.toml",
+        "TMP/main/dep2/git-ws.toml",
         "",
     ]
 
@@ -551,23 +374,6 @@ def test_upgrade(tmp_path):
     manifest_path = tmp_path / "my.toml"
     manifest_path.write_text(
         """
-something = "different"
-
-[[entry]]
-name = "my entry"
-
-[[dependencies]]
-name = 'foo'
-
-# My Comment
-
-[[remotes]]
-name = 'myremote'
-url-base = "my-url"
-
-[[dependencies]]
-name = 'bar'
-
 """
     )
     assert cli(["manifest", "upgrade", "-M", str(manifest_path)]) == [
@@ -578,152 +384,28 @@ def test_upgrade(tmp_path):
         manifest_path.read_text()
         == """version = "1.0"
 ##
-## Welcome to Git Workspace's Manifest. It actually contains 4 parts:
-##
-## * Remotes
-## * Groups
-## * Defaults
-## * Dependencies
+## Git Workspace's Manifest. Please see the documentation at:
 ##
-## =========
-##  Remotes
-## =========
+## https://git-ws.readthedocs.io/en/latest/manual/manifest.html
 ##
-## Remotes just refer to a directory with repositories.
-##
-## We support relative paths for dependencies. So, if your dependencies are next
-## to your repository, you might NOT need any remote.
-## In other terms: You only need remotes if your dependencies are located on
-## OTHER servers than your server with this manifest.
-##
-## Remotes have two attributes:
-## * name: Required. String.
-##         Name of the remote. Any valid string. Must be unique within your
-##         manifest.
-## * url-base: Required. String.
-##             URL Prefix. The project 'name' or 'sub-url' will be appended
-##             later-on.
-##
-# [[remotes]]
-# name = "myremote"
-# url-base = "https://github.com/myuser"
-something = "different"
-[[remotes]]
-name = 'myremote'
-url-base = "my-url"
 
 
-## =========
-##  Groups
-## =========
-##
-## Groups structure dependencies.
-##
-## Groups are optional by default.
-## If a dependency belongs to a group, it becomes optional likewise.
-## Groups can be later on selected/deselected by '+group' or '-group'.
-## An optional group can be selected by '+group',
-## a non-optional group can be deselected by '-group'.
-## Deselection has higher priority than selection.
-##
-## Dependencies can refer to non-existing groups. You do NOT need to specify
-## all used groups.
-##
-## Groups have two attributes:
-## * name: Required. String.
-##         Name of the group. Any valid string. Must be unique within your
-##         manifest.
-## * optional: Optional. Bool. Default is True.
-##             Specifies if the group is optional. Meaning it must be selected
-##             explicitly. Otherwise the dependency is not added by default.
-##
-## The following lines set a group as non-optional.
-# [[groups]]
-# name = "test"
-# optional = false
+# group-filters = ["+test", "-doc", "+feature@path"]
+group-filters = []
 
 
-## ==========
-##  Defaults
-## ==========
-##
-## The 'defaults' section specifies default values for dependencies.
-##
-## * remote: Optional. String.
-##           Remote used as default.
-##           The 'remote' MUST be defined in the 'remotes' section above!
-## * revision: Optional. String.
-##             Revision used as default. Tag or Branch.
-##
-## NOTE: It is recommended to specify a default revision (i.e. 'main').
-##       If a dependency misses 'revision', GitWS will not take care about
-##       revision handling. This may lead to strange side-effects. You
-##       have been warned.
+# [[remotes]]
+# name = "myremote"
+# url-base = "https://github.com/myuser"
+
 
 [defaults]
 # remote = "myserver"
 # revision = "main"
+# groups = ["+test"]
+# with_groups = ["doc"]
 
 
-## ==============
-##  Dependencies
-## ==============
-##
-## The 'dependencies' section specifies all your git clones you need for your
-## project to operate.
-##
-## A dependency has the following attributes:
-## * name: Required. String.
-##         Just name your dependency. It is recommended to choose a
-##         unique name, but not a must.
-## * remote: Optional. String. Restricted (see RESTRICTIONS below).
-##           Remote Alias.
-##           The 'remote' MUST be defined in the 'remotes' section above!
-##           The 'remote' can also be specified in the 'defaults' section.
-## * sub-url: Optional. String. Default: '../{name}[.git]' (see NOTE1 below).
-##            Relative URL to 'url-base' of your specified 'remote'
-##            OR
-##            Relative URL to the URL of the repository containing this
-##            manifest.
-## * url: Optional. String. Restricted (see RESTRICTIONS below).
-##        Absolute URL to the dependent repository.
-## * revision: Optional. String.
-##             Revision to be checked out.
-##             If this attribute is left blank, GitWS does NOT manage the
-##             dependency revision (see NOTE2 below)!
-##             The 'revision' can also be specified in the 'defaults' section.
-## * path: Optional. String. Default is '{name}'.
-##         Project Filesystem Path. Relative to Workspace Root Directory.
-##         The dependency 'name' is used as default for 'path'.
-##         The 'path' MUST be unique within your manifest.
-## * manifest_path: Optional. String. Default: 'git-ws.toml'.
-##                   Path to manifest.
-##                   Relative to 'path'.
-##                   Avoid changing it! It is just additional effort.
-## * groups: Optional. List of Strings.
-##           Dependency Groups.
-##           Dependencies can be categorized into groups.
-##           Groups are optional by default. See 'groups' section above.
-##
-## NOTE1: 'sub-url' is '../{name}[.git]' by default. Meaning if the dependency
-##        is next to your repository containing this manifest, the dependency
-##        is automatically found.
-##        The '.git' suffix is appended if the repository containing this
-##        manifest uses a '.git' suffix.
-##
-## NOTE2: It is recommended to specify a revision (i.e. 'main') either
-##        explicitly or via the 'default' section.
-##        Without a 'revision' GitWS will not take care about revision
-##        handling. This may lead to strange side-effects.
-##        You have been warned.
-##
-## RESTRICTIONS:
-##
-## * `remote` and `url` are mutually exclusive.
-## * `url` and `sub-url` are likewise mutually exclusive
-## * `sub-url` requires a `remote`.
-##
-##
 ## A full flavored dependency using a 'remote':
 # [[dependencies]]
 # name = "myname"
@@ -744,15 +426,6 @@ def test_upgrade(tmp_path):
 ## A minimal dependency:
 # [[dependencies]]
 # name = "my"
-[[dependencies]]
-name = 'foo'
-
-[[dependencies]]
-name = 'bar'
-
-[[entry]]
-name = "my entry"
-
 """
     )
 
diff --git a/tests/test_cli_update.py b/tests/test_cli_update.py
index b021019..84c8fe5 100644
--- a/tests/test_cli_update.py
+++ b/tests/test_cli_update.py
@@ -28,13 +28,13 @@
 @fixture
 def gws(tmp_path, repos):
     """Initialized :any:`GitWS` on `repos`."""
-    workspace = tmp_path / "workspace"
-    workspace.mkdir()
+    workspace = tmp_path / "main"
 
-    with chdir(workspace):
+    with chdir(tmp_path):
         gws = GitWS.clone(str(repos / "main"))
         gws.update(skip_main=True)
 
+    with chdir(workspace):
         yield gws
 
 
@@ -54,26 +54,26 @@ def test_update(tmp_path, repos, gws):
 
     # Update project
     assert cli(["update", "-P", "dep2"]) == [
-        "===== SKIPPING main (MAIN) =====",
-        "===== SKIPPING dep1 =====",
-        "===== dep2 (revision='1-feature') =====",
+        "===== SKIPPING main (MAIN 'main') =====",
+        "===== SKIPPING dep1 ('dep1') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "Pulling branch '1-feature'.",
-        "===== SKIPPING dep4 (revision='main') =====",
+        "===== SKIPPING dep4 ('dep4', revision='main') =====",
         "",
     ]
 
     # Update
     assert cli(["update"], tmp_path=tmp_path) == [
-        "===== main (MAIN) =====",
+        "===== main (MAIN 'main') =====",
         "Pulling branch 'main'.",
-        "===== dep1 =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
         "Pulling branch 'main'.",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "Pulling branch '1-feature'.",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "Pulling branch 'main'.",
-        "===== dep5 =====",
+        "===== dep5 ('dep5') =====",
         "git-ws WARNING Clone dep5 has no revision!",
         "Cloning 'TMP/repos/dep5'.",
         "",
@@ -81,16 +81,16 @@ def test_update(tmp_path, repos, gws):
 
     # Update again
     assert cli(["update"]) == [
-        "===== main (MAIN) =====",
+        "===== main (MAIN 'main') =====",
         "Pulling branch 'main'.",
-        "===== dep1 =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
         "Pulling branch 'main'.",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "Pulling branch '1-feature'.",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "Pulling branch 'main'.",
-        "===== dep5 =====",
+        "===== dep5 ('dep5') =====",
         "git-ws WARNING Clone dep5 has no revision!",
         "Pulling branch 'main'.",
         "",
@@ -98,13 +98,13 @@ def test_update(tmp_path, repos, gws):
 
     # Update other.toml
     assert cli(["update", "--manifest", "other.toml"], tmp_path=tmp_path) == [
-        "===== main (MAIN) =====",
+        "===== main (MAIN 'main') =====",
         "Pulling branch 'main'.",
-        "===== dep1 (revision='main') =====",
+        "===== dep1 ('dep1', revision='main') =====",
         "Pulling branch 'main'.",
-        "===== sub/dep6 ('dep6', revision='main', groups='+foo,+bar,+fast') =====",
+        "===== sub/dep6 ('dep6', revision='main', groups='foo,bar,fast') =====",
         "Cloning 'TMP/repos/dep6'.",
-        "===== dep4 (revision='4-feature') =====",
+        "===== dep4 ('dep4', revision='4-feature') =====",
         "Fetching.",
         "Checking out '4-feature' (previously 'main').",
         "Merging branch '4-feature'.",
@@ -128,40 +128,40 @@ def test_update_rebase(tmp_path, repos, gws):
 
     # Rebase
     assert cli(["update", "--rebase"], tmp_path=tmp_path) == [
-        "===== main (MAIN) =====",
+        "===== main (MAIN 'main') =====",
         "Fetching.",
         "Rebasing branch 'main'.",
-        "===== dep1 =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
         "Fetching.",
         "Rebasing branch 'main'.",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "Fetching.",
         "Rebasing branch '1-feature'.",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "Fetching.",
         "Rebasing branch 'main'.",
-        "===== dep5 =====",
+        "===== dep5 ('dep5') =====",
         "git-ws WARNING Clone dep5 has no revision!",
         "Cloning 'TMP/repos/dep5'.",
         "",
     ]
 
     assert cli(["update", "--rebase"]) == [
-        "===== main (MAIN) =====",
+        "===== main (MAIN 'main') =====",
         "Fetching.",
         "Rebasing branch 'main'.",
-        "===== dep1 =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
         "Fetching.",
         "Rebasing branch 'main'.",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "Fetching.",
         "Rebasing branch '1-feature'.",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "Fetching.",
         "Rebasing branch 'main'.",
-        "===== dep5 =====",
+        "===== dep5 ('dep5') =====",
         "git-ws WARNING Clone dep5 has no revision!",
         "Fetching.",
         "Rebasing branch 'main'.",
@@ -169,15 +169,15 @@ def test_update_rebase(tmp_path, repos, gws):
     ]
 
     assert cli(["update", "--manifest", "other.toml", "--rebase"], tmp_path=tmp_path) == [
-        "===== main (MAIN) =====",
+        "===== main (MAIN 'main') =====",
         "Fetching.",
         "Rebasing branch 'main'.",
-        "===== dep1 (revision='main') =====",
+        "===== dep1 ('dep1', revision='main') =====",
         "Fetching.",
         "Rebasing branch 'main'.",
-        "===== sub/dep6 ('dep6', revision='main', groups='+foo,+bar,+fast') =====",
+        "===== sub/dep6 ('dep6', revision='main', groups='foo,bar,fast') =====",
         "Cloning 'TMP/repos/dep6'.",
-        "===== dep4 (revision='4-feature') =====",
+        "===== dep4 ('dep4', revision='4-feature') =====",
         "Fetching.",
         "Checking out '4-feature' (previously 'main').",
         "Rebasing branch '4-feature'.",
@@ -185,11 +185,11 @@ def test_update_rebase(tmp_path, repos, gws):
     ]
 
     assert cli(["status"]) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
-        "===== dep4 (revision='main') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "git-ws WARNING Clone dep4 (revision='main') is on different revision: '4-feature'",
         "",
     ]
diff --git a/tests/test_cli_workflow.py b/tests/test_cli_workflow.py
index 14c0b62..dd44732 100644
--- a/tests/test_cli_workflow.py
+++ b/tests/test_cli_workflow.py
@@ -29,13 +29,13 @@ def gws(tmp_path, repos):
     """Initialized :any:`GitWS` on `repos`."""
     # pylint: disable=unused-argument
 
-    workspace = tmp_path / "workspace"
-    workspace.mkdir()
+    workspace = tmp_path / "main"
 
-    with chdir(workspace):
+    with chdir(tmp_path):
         gws = GitWS.clone(str(repos / "main"))
         gws.update(skip_main=True)
 
+    with chdir(workspace):
         yield gws
 
 
@@ -43,28 +43,28 @@ def test_status(tmp_path, gws):
     """Test status."""
     # pylint: disable=unused-argument
 
-    workspace = tmp_path / "workspace"
+    workspace = tmp_path / "main"
     dep1 = workspace / "dep1"
     dep2 = workspace / "dep2"
 
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
-        "===== dep4 (revision='main') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "",
     ]
 
     assert cli(("status", "--branch")) == [
-        "===== main (MAIN) =====",
+        "===== main (MAIN 'main') =====",
         "## main...origin/main",
-        "===== dep1 =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
         "## main...origin/main",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "## 1-feature...origin/1-feature",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "## main...origin/main",
         "",
     ]
@@ -74,23 +74,23 @@ def test_status(tmp_path, gws):
     (dep2 / "bc.txt").touch()
 
     assert cli(("status", "--branch")) == [
-        "===== main (MAIN) =====",
+        "===== main (MAIN 'main') =====",
         "## main...origin/main",
-        "===== dep1 =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
         "## main...origin/main",
         "?? dep1/foo.txt",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "## 1-feature...origin/1-feature",
         "?? dep2/bb.txt",
         "?? dep2/bc.txt",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "## main...origin/main",
         "",
     ]
 
     assert cli(("status", "--branch", "dep2")) == [
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "## 1-feature...origin/1-feature",
         "?? dep2/bb.txt",
         "?? dep2/bc.txt",
@@ -98,7 +98,7 @@ def test_status(tmp_path, gws):
     ]
 
     assert cli(("status", "dep2/bc.txt")) == [
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "?? dep2/bc.txt",
         "",
     ]
@@ -108,7 +108,7 @@ def test_workflow(tmp_path, gws):
     """Test Full Workflow."""
     # pylint: disable=unused-argument
 
-    workspace = tmp_path / "workspace"
+    workspace = tmp_path / "main"
     dep1 = workspace / "dep1"
     dep2 = workspace / "dep2"
     dep4 = workspace / "dep4"
@@ -129,20 +129,20 @@ def test_workflow(tmp_path, gws):
     git4.add(("foo.txt",))
 
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
         "?? dep1/foo.txt",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "?? dep2/bb.txt",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "A  dep4/foo.txt",
         "",
     ]
 
     with chdir(dep1):
         assert cli(("status",)) == [
-            "===== ../main (MAIN) =====",
+            "===== ../main (MAIN 'main') =====",
             "===== . ('dep1') =====",
             "git-ws WARNING Clone dep1 has no revision!",
             "?? foo.txt",
@@ -159,13 +159,13 @@ def test_workflow(tmp_path, gws):
     ]
 
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
         "?? dep1/foo.txt",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "?? dep2/bb.txt",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "A  dep4/foo.txt",
         "",
     ]
@@ -173,13 +173,13 @@ def test_workflow(tmp_path, gws):
     assert cli(("add", "dep2/bb.txt", "dep1/foo.txt")) == [""]
 
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
         "A  dep1/foo.txt",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "A  dep2/bb.txt",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "A  dep4/foo.txt",
         "",
     ]
@@ -190,33 +190,33 @@ def test_workflow(tmp_path, gws):
     ]
 
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
         "A  dep1/foo.txt",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "A  dep2/bb.txt",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "A  dep4/foo.txt",
         "",
     ]
 
     assert cli(("commit", "dep2/bb.txt", "dep4/foo.txt", "-m", "messi")) == [
-        "===== dep2 (revision='1-feature') =====",
-        "===== dep4 (revision='main') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "",
     ]
 
     assert cli(("status", "--branch")) == [
-        "===== main (MAIN) =====",
+        "===== main (MAIN 'main') =====",
         "## main...origin/main",
-        "===== dep1 =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
         "## main...origin/main",
         "A  dep1/foo.txt",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "## 1-feature...origin/1-feature [ahead 1]",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "## main...origin/main [ahead 1]",
         "",
     ]
@@ -226,31 +226,31 @@ def test_workflow(tmp_path, gws):
     (dep2 / "barbar.txt").touch()
 
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
         "?? dep1/foo.txt",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "?? dep2/barbar.txt",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "",
     ]
 
     assert cli(("commit", "-m", "test")) == [""]
 
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
         "?? dep1/foo.txt",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "?? dep2/barbar.txt",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "",
     ]
 
     assert cli(("status", "dep1")) == [
-        "===== dep1 =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
         "?? dep1/foo.txt",
         "",
@@ -259,66 +259,66 @@ def test_workflow(tmp_path, gws):
     assert cli(("add", "dep1", "--all", "--force")) == [""]
 
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
         "A  dep1/foo.txt",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "?? dep2/barbar.txt",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "",
     ]
 
     assert cli(("commit", "-m", "changes")) == [
-        "===== dep1 =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
         "",
     ]
 
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "?? dep2/barbar.txt",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "",
     ]
 
     (dep4 / "foo.txt").write_text("content")
 
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "?? dep2/barbar.txt",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         " M dep4/foo.txt",
         "",
     ]
 
-    assert cli(("commit", "-m", "files", "--all")) == ["===== dep4 (revision='main') =====", ""]
+    assert cli(("commit", "-m", "files", "--all")) == ["===== dep4 ('dep4', revision='main') =====", ""]
 
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "?? dep2/barbar.txt",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "",
     ]
 
     assert cli(("commit", "-m", "files", "--all")) == [""]
 
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "?? dep2/barbar.txt",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "",
     ]
 
@@ -332,12 +332,12 @@ def test_workflow(tmp_path, gws):
     assert cli(("rm", "dep4/foo.txt")) == [""]
 
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "?? dep2/barbar.txt",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "D  dep4/foo.txt",
         "",
     ]
@@ -349,7 +349,7 @@ def test_checkout_file(tmp_path, gws):
     """Checkout files."""
     # pylint: disable=unused-argument
 
-    workspace = tmp_path / "workspace"
+    workspace = tmp_path / "main"
     dep2 = workspace / "dep2"
     dep4 = workspace / "dep4"
 
@@ -357,51 +357,51 @@ def test_checkout_file(tmp_path, gws):
     (dep4 / "data.txt").write_text("Other Change")
 
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         " M dep2/data.txt",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         " M dep4/data.txt",
         "",
     ]
 
     assert cli(("checkout", "dep1/missing.txt"), exit_code=1) == [
-        "===== dep1 =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
         "Error: Command '['git', 'checkout', '--', 'missing.txt']' returned non-zero exit status 1.",
         "",
     ]
 
     assert cli(("checkout", "dep1/data.txt")) == [
-        "===== dep1 =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
         "",
     ]
 
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         " M dep2/data.txt",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         " M dep4/data.txt",
         "",
     ]
 
     assert cli(("checkout", "dep2/data.txt")) == [
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "",
     ]
 
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
-        "===== dep4 (revision='main') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         " M dep4/data.txt",
         "",
     ]
@@ -411,7 +411,7 @@ def test_checkout(tmp_path, gws):
     """Checkout files."""
     # pylint: disable=unused-argument
 
-    workspace = tmp_path / "workspace"
+    workspace = tmp_path / "main"
     dep2 = workspace / "dep2"
     dep4 = workspace / "dep4"
 
@@ -419,51 +419,51 @@ def test_checkout(tmp_path, gws):
     (dep4 / "data.txt").write_text("Other Change")
 
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         " M dep2/data.txt",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         " M dep4/data.txt",
         "",
     ]
 
     assert cli(("checkout")) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
-        "===== dep4 (revision='main') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "",
     ]
 
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         " M dep2/data.txt",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         " M dep4/data.txt",
         "",
     ]
 
     assert cli(("checkout", "--force")) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
-        "===== dep4 (revision='main') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "",
     ]
 
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
-        "===== dep4 (revision='main') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "",
     ]
 
@@ -472,7 +472,7 @@ def test_add(tmp_path, gws):
     """Add."""
     # pylint: disable=unused-argument
 
-    workspace = tmp_path / "workspace"
+    workspace = tmp_path / "main"
     dep2 = workspace / "dep2"
     dep4 = workspace / "dep4"
 
@@ -480,12 +480,12 @@ def test_add(tmp_path, gws):
     (dep4 / "data.txt").write_text("Other Change")
 
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         " M dep2/data.txt",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         " M dep4/data.txt",
         "",
     ]
@@ -495,12 +495,12 @@ def test_add(tmp_path, gws):
     ]
     assert cli(("add", "--all")) == [""]
     assert cli(("status",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "M  dep2/data.txt",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "M  dep4/data.txt",
         "",
     ]
@@ -510,7 +510,7 @@ def test_diff(tmp_path, gws):
     """diff."""
     # pylint: disable=unused-argument
 
-    workspace = tmp_path / "workspace"
+    workspace = tmp_path / "main"
     dep2 = workspace / "dep2"
     dep4 = workspace / "dep4"
 
@@ -518,37 +518,37 @@ def test_diff(tmp_path, gws):
     (dep4 / "data.txt").write_text("Other Text")
 
     assert cli(("diff",)) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
-        "===== dep4 (revision='main') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         "",
     ]
     assert cli(("diff", "dep2")) == [
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         "",
     ]
 
     assert cli(("diff", "--stat")) == [
-        "===== main (MAIN) =====",
-        "===== dep1 =====",
+        "===== main (MAIN 'main') =====",
+        "===== dep1 ('dep1') =====",
         "git-ws WARNING Clone dep1 has no revision!",
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         " dep2/data.txt | 2 +-",
-        "===== dep4 (revision='main') =====",
+        "===== dep4 ('dep4', revision='main') =====",
         " dep4/data.txt | 2 +-",
         "",
     ]
     assert cli(("diff", "dep2", "--stat")) == [
-        "===== dep2 (revision='1-feature') =====",
+        "===== dep2 ('dep2', revision='1-feature') =====",
         " dep2/data.txt | 2 +-",
         "",
     ]
 
     with chdir(dep2):
         assert cli(("diff",)) == [
-            "===== ../main (MAIN) =====",
+            "===== ../main (MAIN 'main') =====",
             "===== ../dep1 ('dep1') =====",
             "git-ws WARNING Clone dep1 has no revision!",
             "===== . ('dep2', revision='1-feature') =====",
@@ -556,7 +556,7 @@ def test_diff(tmp_path, gws):
             "",
         ]
         assert cli(("diff", "--stat")) == [
-            "===== ../main (MAIN) =====",
+            "===== ../main (MAIN 'main') =====",
             "===== ../dep1 ('dep1') =====",
             "git-ws WARNING Clone dep1 has no revision!",
             "===== . ('dep2', revision='1-feature') =====",
diff --git a/tests/test_clone.py b/tests/test_clone.py
index 56a10fc..d933ef7 100644
--- a/tests/test_clone.py
+++ b/tests/test_clone.py
@@ -15,261 +15,24 @@
 # with Git Workspace. If not, see <https://www.gnu.org/licenses/>.
 
 """Clone Testing."""
-from gitws import GitWS, Group, Manifest, Project
+from pytest import raises
+
+from gitws import GitCloneNotCleanError, GitWS, Manifest, Project
 
 # pylint: disable=unused-import
 from .fixtures import repos
-from .util import chdir, cli
-
-
-def check(workspace, name, content=None, exists=True):
-    """Check."""
-    file_path = workspace / name / "data.txt"
-    content = content or name
-    if exists:
-        assert file_path.exists()
-        assert file_path.read_text() == f"{content}"
-    else:
-        assert not file_path.exists()
-
-
-def test_cli_clone(tmp_path, repos):
-    """Cloning via CLI."""
-    workspace = tmp_path / "workspace"
-    workspace.mkdir()
-
-    with chdir(workspace):
-        assert cli(["clone", str(repos / "main")]) == [
-            "===== main (MAIN) =====",
-            f"Cloning '{tmp_path}/repos/main'.",
-            "Workspace initialized at '.'. Please continue with:",
-            "",
-            "    git ws update",
-            "",
-            "",
-        ]
-
-        check(workspace, "main")
-        check(workspace, "dep1", exists=False)
-        check(workspace, "dep2", exists=False)
-        check(workspace, "dep3", exists=False)
-        check(workspace, "dep4", exists=False)
-        check(workspace, "dep5", exists=False)
-
-
-def test_cli_clone_path(tmp_path, repos):
-    """Cloning via CLI."""
-    workspace = tmp_path / "workspace"
-    workspace.mkdir()
-
-    with chdir(workspace):
-        assert cli(["clone", str(repos / "main"), "main2"]) == [
-            "===== main2 (MAIN) =====",
-            f"Cloning '{tmp_path}/repos/main'.",
-            "Workspace initialized at '.'. Please continue with:",
-            "",
-            "    git ws update",
-            "",
-            "",
-        ]
-
-        check(workspace, "main", exists=False)
-        check(workspace, "main2", content="main")
-        check(workspace, "dep1", exists=False)
-        check(workspace, "dep2", exists=False)
-        check(workspace, "dep3", exists=False)
-        check(workspace, "dep4", exists=False)
-        check(workspace, "dep5", exists=False)
-
-        assert cli(["checkout"], tmp_path=tmp_path) == [
-            "===== main2 (MAIN) =====",
-            "===== dep1 =====",
-            "Cloning 'TMP/repos/dep1'.",
-            "git-ws WARNING Clone dep1 has no revision!",
-            "===== dep2 (revision='1-feature') =====",
-            "Cloning 'TMP/repos/dep2'.",
-            "===== dep4 (revision='main') =====",
-            "Cloning 'TMP/repos/dep4'.",
-            "",
-        ]
-
-        check(workspace, "main", exists=False)
-        check(workspace, "main2", content="main")
-        check(workspace, "dep1")
-        check(workspace, "dep2", content="dep2-feature")
-        check(workspace, "dep3", exists=False)
-        check(workspace, "dep4")
-        check(workspace, "dep5", exists=False)
-
-
-def test_cli_clone_not_empty(tmp_path, repos):
-    """Cloning via CLI not empty."""
-    workspace = tmp_path / "workspace"
-    workspace.mkdir()
-
-    with chdir(workspace):
-        (workspace / "file.txt").touch()
-        assert cli(["clone", str(repos / "main")], exit_code=1) == [
-            "Error: Workspace '.' is not an empty directory.",
-            "",
-            "Choose an empty directory or use '--force'",
-            "",
-            "",
-        ]
-
-        check(workspace, "main", exists=False)
-        check(workspace, "dep1", exists=False)
-        check(workspace, "dep2", exists=False)
-        check(workspace, "dep3", exists=False)
-        check(workspace, "dep4", exists=False)
-        check(workspace, "dep5", exists=False)
-
-        assert cli(["clone", str(repos / "main"), "--force"], tmp_path=tmp_path) == [
-            "===== main (MAIN) =====",
-            "Cloning 'TMP/repos/main'.",
-            "Workspace initialized at '.'. Please continue with:",
-            "",
-            "    git ws update",
-            "",
-            "",
-        ]
-
-        check(workspace, "main")
-        check(workspace, "dep1", exists=False)
-        check(workspace, "dep2", exists=False)
-        check(workspace, "dep3", exists=False)
-        check(workspace, "dep4", exists=False)
-        check(workspace, "dep5", exists=False)
-
-
-def test_cli_clone_update(tmp_path, repos):
-    """Cloning via CLI."""
-    workspace = tmp_path / "workspace"
-    workspace.mkdir()
-
-    with chdir(workspace):
-        assert cli(["clone", str(repos / "main"), "--update"]) == [
-            "===== main (MAIN) =====",
-            f"Cloning '{tmp_path}/repos/main'.",
-            "===== dep1 =====",
-            "git-ws WARNING Clone dep1 has no revision!",
-            f"Cloning '{tmp_path}/repos/dep1'.",
-            "===== dep2 (revision='1-feature') =====",
-            f"Cloning '{tmp_path}/repos/dep2'.",
-            "===== dep4 (revision='main') =====",
-            f"Cloning '{tmp_path}/repos/dep4'.",
-            "",
-        ]
-
-    check(workspace, "main")
-    check(workspace, "dep1")
-    check(workspace, "dep2", content="dep2-feature")
-    check(workspace, "dep3", exists=False)
-    check(workspace, "dep4")
-    check(workspace, "dep5", exists=False)
-
-
-def test_cli_clone_checkout(tmp_path, repos):
-    """Cloning via CLI and checkout."""
-    workspace = tmp_path / "workspace"
-    workspace.mkdir()
-
-    with chdir(workspace):
-        assert cli(["clone", str(repos / "main")]) == [
-            "===== main (MAIN) =====",
-            f"Cloning '{tmp_path}/repos/main'.",
-            "Workspace initialized at '.'. Please continue with:",
-            "",
-            "    git ws update",
-            "",
-            "",
-        ]
-
-        check(workspace, "main")
-        check(workspace, "dep1", exists=False)
-        check(workspace, "dep2", exists=False)
-        check(workspace, "dep3", exists=False)
-        check(workspace, "dep4", exists=False)
-        check(workspace, "dep5", exists=False)
-
-    with chdir(workspace / "main"):
-        assert cli(["checkout"], tmp_path=tmp_path) == [
-            "===== . (MAIN) =====",
-            "===== ../dep1 ('dep1') =====",
-            "Cloning 'TMP/repos/dep1'.",
-            "git-ws WARNING Clone dep1 has no revision!",
-            "===== ../dep2 ('dep2', revision='1-feature') =====",
-            "Cloning 'TMP/repos/dep2'.",
-            "===== ../dep4 ('dep4', revision='main') =====",
-            "Cloning 'TMP/repos/dep4'.",
-            "",
-        ]
-
-        check(workspace, "main")
-        check(workspace, "dep1")
-        check(workspace, "dep2", content="dep2-feature")
-        check(workspace, "dep3", exists=False)
-        check(workspace, "dep4")
-        check(workspace, "dep5", exists=False)
-
-
-def test_cli_clone_groups(tmp_path, repos):
-    """Cloning via CLI with groups."""
-    workspace = tmp_path / "workspace"
-    workspace.mkdir()
-
-    with chdir(workspace):
-        assert cli(["clone", str(repos / "main"), "-G", "+test"]) == [
-            "===== main (MAIN) =====",
-            "Cloning " f"'{tmp_path}/repos/main'.",
-            "Workspace initialized at '.'. Please continue with:",
-            "",
-            "    git ws update",
-            "",
-            "",
-        ]
-
-        check(workspace, "main")
-        check(workspace, "dep1", exists=False)
-        check(workspace, "dep2", exists=False)
-        check(workspace, "dep3", exists=False)
-        check(workspace, "dep4", exists=False)
-        check(workspace, "dep5", exists=False)
-
-        assert cli(["update"]) == [
-            "Groups: '+test'",
-            "===== main (MAIN) =====",
-            "Pulling branch 'main'.",
-            "===== dep1 =====",
-            "git-ws WARNING Clone dep1 has no revision!",
-            f"Cloning '{tmp_path}/repos/dep1'.",
-            "===== dep2 (revision='1-feature') =====",
-            f"Cloning '{tmp_path}/repos/dep2'.",
-            "===== dep4 (revision='main') =====",
-            f"Cloning '{tmp_path}/repos/dep4'.",
-            "===== dep3 (revision='main', groups='test') =====",
-            f"Cloning '{tmp_path}/repos/dep3'.",
-            "",
-        ]
-
-        check(workspace, "main")
-        check(workspace, "dep1")
-        check(workspace, "dep2", content="dep2-feature")
-        check(workspace, "dep3")
-        check(workspace, "dep4")
-        check(workspace, "dep5", exists=False)
+from .util import chdir, check
 
 
 def test_clone(tmp_path, repos):
     """Test Cloning."""
 
-    workspace = tmp_path / "workspace"
-    workspace.mkdir()
+    workspace = tmp_path / "main"
 
-    with chdir(workspace):
-        main_path = repos / "main"
-        gws = GitWS.clone(str(main_path))
+    with chdir(tmp_path):
+        gws = GitWS.clone(str(repos / "main"))
         assert gws.path == workspace
+
         gws.update()
         assert gws.get_manifest().path == str(workspace / "main" / "git-ws.toml")
 
@@ -280,8 +43,9 @@ def test_clone(tmp_path, repos):
         check(workspace, "dep4")
         check(workspace, "dep5", exists=False)
 
-        rrepo = GitWS.from_path()
-        assert gws == rrepo
+        with chdir(workspace):
+            rrepo = GitWS.from_path()
+            assert gws == rrepo
 
         assert list(gws.projects()) == [
             Project(name="main", path="main", is_main=True),
@@ -291,42 +55,44 @@ def test_clone(tmp_path, repos):
         ]
         assert list(gws.manifests()) == [
             Manifest(
+                group_filters=("-test",),
                 dependencies=(
                     Project(name="dep1", path="dep1", url="../dep1"),
                     Project(name="dep2", path="dep2", url="../dep2", revision="1-feature"),
+                    Project(name="dep3", path="dep3", url="../dep3", groups=("test",)),
                 ),
-                path=str(tmp_path / "workspace/main/git-ws.toml"),
+                path=str(workspace / "main" / "git-ws.toml"),
             ),
             Manifest(
                 dependencies=(Project(name="dep4", path="dep4", url="../dep4", revision="main"),),
-                path=str(tmp_path / "workspace/dep1/git-ws.toml"),
+                path=str(workspace / "dep1" / "git-ws.toml"),
             ),
             Manifest(
-                groups=(Group(name="test"),),
+                group_filters=("-test",),
                 dependencies=(
-                    Project(name="dep3", path="dep3", url="../dep3", revision="main", groups=(Group(name="test"),)),
+                    Project(name="dep3", path="dep3", url="../dep3", revision="main", groups=("test",)),
                     Project(name="dep4", path="dep4", url="../dep4", revision="main"),
                 ),
-                path=str(tmp_path / "workspace/dep2/git-ws.toml"),
+                path=str(workspace / "dep2" / "git-ws.toml"),
             ),
         ]
         assert list(str(item) for item in gws.clones()) == [
-            "Clone(Project(name='main', path='main', is_main=True), Git(PosixPath('main')))",
-            "Clone(Project(name='dep1', path='dep1', url='../dep1'), Git(PosixPath('dep1')))",
-            "Clone(Project(name='dep2', path='dep2', url='../dep2', revision='1-feature'), Git(PosixPath('dep2')))",
-            "Clone(Project(name='dep4', path='dep4', url='../dep4', revision='main'), Git(PosixPath('dep4')))",
+            "Clone(Project(name='main', path='main', is_main=True), Git(PosixPath('main/main')))",
+            "Clone(Project(name='dep1', path='dep1', url='../dep1'), Git(PosixPath('main/dep1')))",
+            "Clone(Project(name='dep2', path='dep2', url='../dep2', revision='1-feature'), "
+            "Git(PosixPath('main/dep2')))",
+            "Clone(Project(name='dep4', path='dep4', url='../dep4', revision='main'), Git(PosixPath('main/dep4')))",
         ]
 
 
 def test_clone_groups(tmp_path, repos):
     """Test Cloning."""
 
-    workspace = tmp_path / "workspace"
-    workspace.mkdir()
+    workspace = tmp_path / "main"
 
-    with chdir(workspace):
+    with chdir(tmp_path):
         main_path = repos / "main"
-        gws = GitWS.clone(str(main_path), groups="+test")
+        gws = GitWS.clone(str(main_path), group_filters=("+test",))
         gws.update()
         assert gws.get_manifest().path == str(workspace / "main" / "git-ws.toml")
 
@@ -337,67 +103,60 @@ def test_clone_groups(tmp_path, repos):
         check(workspace, "dep4")
         check(workspace, "dep5", exists=False)
 
-        rrepo = GitWS.from_path()
-        assert gws == rrepo
+        with chdir(workspace):
+            rrepo = GitWS.from_path()
+            assert gws == rrepo
 
         assert list(gws.projects()) == [
             Project(name="main", path="main", is_main=True),
             Project(name="dep1", path="dep1", url="../dep1"),
             Project(name="dep2", path="dep2", url="../dep2", revision="1-feature"),
+            Project(name="dep3", path="dep3", url="../dep3", groups=("test",)),
             Project(name="dep4", path="dep4", url="../dep4", revision="main"),
-            Project(name="dep3", path="dep3", url="../dep3", revision="main", groups=(Group(name="test"),)),
-        ]
-        assert list(gws.projects(filter_=lambda project: not project.is_main)) == [
-            Project(name="dep1", path="dep1", url="../dep1"),
-            Project(name="dep2", path="dep2", url="../dep2", revision="1-feature"),
-            Project(name="dep4", path="dep4", url="../dep4", revision="main"),
-            Project(name="dep3", path="dep3", url="../dep3", revision="main", groups=(Group(name="test"),)),
         ]
         assert list(gws.manifests()) == [
             Manifest(
+                group_filters=("-test",),
                 dependencies=(
                     Project(name="dep1", path="dep1", url="../dep1"),
                     Project(name="dep2", path="dep2", url="../dep2", revision="1-feature"),
+                    Project(name="dep3", path="dep3", url="../dep3", groups=("test",)),
                 ),
-                path=str(tmp_path / "workspace/main/git-ws.toml"),
+                path=str(workspace / "main" / "git-ws.toml"),
             ),
             Manifest(
                 dependencies=(Project(name="dep4", path="dep4", url="../dep4", revision="main"),),
-                path=str(tmp_path / "workspace/dep1/git-ws.toml"),
+                path=str(workspace / "dep1" / "git-ws.toml"),
             ),
             Manifest(
-                groups=(Group(name="test"),),
+                group_filters=("-test",),
                 dependencies=(
-                    Project(name="dep3", path="dep3", url="../dep3", revision="main", groups=(Group(name="test"),)),
+                    Project(name="dep3", path="dep3", url="../dep3", revision="main", groups=("test",)),
                     Project(name="dep4", path="dep4", url="../dep4", revision="main"),
                 ),
-                path=str(tmp_path / "workspace/dep2/git-ws.toml"),
+                path=str(workspace / "dep2" / "git-ws.toml"),
             ),
             Manifest(
                 dependencies=(Project(name="dep2", path="dep2", url="../dep2"),),
-                path=str(tmp_path / "workspace/dep3/git-ws.toml"),
+                path=str(workspace / "dep3" / "git-ws.toml"),
             ),
         ]
         assert list(str(item) for item in gws.clones()) == [
-            "Clone(Project(name='main', path='main', is_main=True), Git(PosixPath('main')))",
-            "Clone(Project(name='dep1', path='dep1', url='../dep1'), Git(PosixPath('dep1')))",
-            "Clone(Project(name='dep2', path='dep2', url='../dep2', revision='1-feature'), Git(PosixPath('dep2')))",
-            "Clone(Project(name='dep4', path='dep4', url='../dep4', revision='main'), Git(PosixPath('dep4')))",
-            "Clone(Project(name='dep3', path='dep3', url='../dep3', revision='main', "
-            "groups=(Group(name='test'),)), Git(PosixPath('dep3')))",
+            "Clone(Project(name='main', path='main', is_main=True), Git(PosixPath('main/main')))",
+            "Clone(Project(name='dep1', path='dep1', url='../dep1'), Git(PosixPath('main/dep1')))",
+            "Clone(Project(name='dep2', path='dep2', url='../dep2', "
+            "revision='1-feature'), Git(PosixPath('main/dep2')))",
+            "Clone(Project(name='dep3', path='dep3', url='../dep3', groups=('test',)), Git(PosixPath('main/dep3')))",
+            "Clone(Project(name='dep4', path='dep4', url='../dep4', revision='main'), Git(PosixPath('main/dep4')))",
         ]
 
-        assert list(gws.projects(manifest_path="missing.toml")) == [Project(name="main", path="main", is_main=True)]
-        assert not list(gws.manifests(manifest_path="missing.toml"))
-
 
 def test_clone_other(tmp_path, repos):
     """Test Clone Other."""
 
-    workspace = tmp_path / "workspace"
-    workspace.mkdir()
+    workspace = tmp_path / "main"
 
-    with chdir(workspace):
+    with chdir(tmp_path):
         main_path = repos / "main"
         gws = GitWS.clone(str(main_path), manifest_path="other.toml")
         assert gws.path == workspace
@@ -411,10 +170,11 @@ def test_clone_other(tmp_path, repos):
         check(workspace, "dep4", content="dep4-feature")
         check(workspace, "dep5", exists=False)
 
-        rrepo = GitWS.from_path()
-        assert gws == rrepo
+        with chdir(workspace):
+            rrepo = GitWS.from_path()
+            assert gws == rrepo
 
-        gws.update(manifest_path="git-ws.toml")
+        GitWS.from_path(manifest_path="git-ws.toml", path=workspace).update()
         assert gws.get_manifest().path == str(workspace / "main" / "other.toml")
 
         check(workspace, "main")
@@ -424,22 +184,7 @@ def test_clone_other(tmp_path, repos):
         check(workspace, "dep4")
         check(workspace, "dep5", exists=False)
 
-        assert cli(("update",)) == [
-            "===== main (MAIN) =====",
-            "Pulling branch 'main'.",
-            "===== dep1 (revision='main') =====",
-            "Pulling branch 'main'.",
-            "===== sub/dep6 ('dep6', revision='main', groups='+foo,+bar,+fast') =====",
-            "Pulling branch 'main'.",
-            "===== dep4 (revision='4-feature') =====",
-            "Fetching.",
-            "Checking out '4-feature' (previously 'main').",
-            "Merging branch '4-feature'.",
-            "",
-        ]
-
-        # Disable color here, to test normal error output
-        assert cli(("config", "set", "color_ui", "False")) == [""]
+        gws.update()
 
         check(workspace, "main")
         check(workspace, "dep1")
@@ -451,23 +196,8 @@ def test_clone_other(tmp_path, repos):
         (workspace / "dep5").touch()
         (workspace / "dep2" / "file.txt").touch()
 
-        assert cli(("update", "--prune"), exit_code=1) == [
-            "===== main (MAIN) =====",
-            "Pulling branch 'main'.",
-            "===== dep1 (revision='main') =====",
-            "Pulling branch 'main'.",
-            "===== sub/dep6 ('dep6', revision='main', groups='+foo,+bar,+fast') =====",
-            "Pulling branch 'main'.",
-            "===== dep4 (revision='4-feature') =====",
-            "Pulling branch '4-feature'.",
-            "===== dep2 (OBSOLETE) =====",
-            "Removing 'dep2'.",
-            "Error: Git Clone 'dep2' contains changes.",
-            "",
-            "Commit/Push all your changes and branches or use '--force'",
-            "",
-            "",
-        ]
+        with raises(GitCloneNotCleanError):
+            gws.update(prune=True)
 
         check(workspace, "main")
         check(workspace, "dep1")
@@ -476,19 +206,7 @@ def test_clone_other(tmp_path, repos):
         check(workspace, "dep4", content="dep4-feature")
         check(workspace, "dep5", exists=False)
 
-        assert cli(("update", "--prune", "--force")) == [
-            "===== main (MAIN) =====",
-            "Pulling branch 'main'.",
-            "===== dep1 (revision='main') =====",
-            "Pulling branch 'main'.",
-            "===== sub/dep6 ('dep6', revision='main', groups='+foo,+bar,+fast') =====",
-            "Pulling branch 'main'.",
-            "===== dep4 (revision='4-feature') =====",
-            "Pulling branch '4-feature'.",
-            "===== dep2 (OBSOLETE) =====",
-            "Removing 'dep2'.",
-            "",
-        ]
+        gws.update(prune=True, force=True)
 
         check(workspace, "main")
         check(workspace, "dep1")
diff --git a/tests/test_datamodel.py b/tests/test_datamodel.py
index cbffcdb..2fdc06a 100644
--- a/tests/test_datamodel.py
+++ b/tests/test_datamodel.py
@@ -17,7 +17,7 @@
 """Manifest Testing."""
 from pytest import raises
 
-from gitws.datamodel import Defaults, Group, Manifest, ManifestSpec, Project, ProjectSpec, Remote
+from gitws import Defaults, Groups, GroupSelect, GroupSelects, Manifest, ManifestSpec, Project, ProjectSpec, Remote
 
 from .common import MANIFEST_DEFAULT
 
@@ -42,33 +42,82 @@ def test_defaults():
     defaults = Defaults()
     assert defaults.remote is None
     assert defaults.revision is None
+    assert defaults.groups == tuple()
+    assert defaults.with_groups == tuple()
 
     defaults = Defaults(remote="remote")
     assert defaults.remote == "remote"
     assert defaults.revision is None
+    assert defaults.groups == tuple()
+    assert defaults.with_groups == tuple()
 
     defaults = Defaults(revision="Revision")
     assert defaults.remote is None
     assert defaults.revision == "Revision"
+    assert defaults.groups == tuple()
+    assert defaults.with_groups == tuple()
+
+    defaults = Defaults(groups=("test", "doc"))
+    assert defaults.remote is None
+    assert defaults.revision is None
+    assert defaults.groups == ("test", "doc")
+    assert defaults.with_groups == tuple()
+
+    defaults = Defaults(with_groups=("test", "doc"))
+    assert defaults.remote is None
+    assert defaults.revision is None
+    assert defaults.groups == tuple()
+    assert defaults.with_groups == ("test", "doc")
 
     # Immutable
     with raises(TypeError):
         defaults.remote = "other"
 
+    with raises(ValueError):
+        Defaults(groups=("-foo"))
+
+    with raises(ValueError):
+        Defaults(with_groups=("-foo"))
+
+
+def test_group_select():
+    """Group Select."""
+    group_select = GroupSelect(group="foo", select=True, path="path")
+    assert group_select.group == "foo"
+    assert group_select.select
+    assert group_select.path == "path"
+    assert str(group_select) == "+foo@path"
+
+    # Immutable
+    with raises(TypeError):
+        group_select.group = "blub"
+
 
-def test_project_group():
-    """Group."""
-    group = Group(name="name")
-    assert group.name == "name"
-    assert group.optional
+def test_group_selects():
+    """Group Selects."""
+    group_selects = GroupSelects.from_group_filters(("-test", "+doc", "+feature@path"))
+    assert [str(group_select) for group_select in group_selects] == ["-test", "+doc", "+feature@path"]
 
-    group = Group(name="name", optional=False)
-    assert group.name == "name"
-    assert not group.optional
+
+def test_project():
+    """Test ProjectSpec."""
+    project = Project(name="name", path="path")
+    assert project.name == "name"
+    assert project.url is None
+    assert project.revision is None
+    assert project.manifest_path == "git-ws.toml"
+    assert project.groups == Groups()
+    assert project.with_groups == Groups()
 
     # Immutable
     with raises(TypeError):
-        group.name = "other"
+        project.name = "other"
+
+    with raises(ValueError):
+        Project(name="name", path="path", groups=("-foo",))
+
+    # with raises(ValueError):
+    Project(name="name", path="path", with_groups=("-foo",))
 
 
 def test_project_spec():
@@ -79,6 +128,10 @@ def test_project_spec():
     assert project_spec.sub_url is None
     assert project_spec.url is None
     assert project_spec.revision is None
+    assert project_spec.path is None
+    assert project_spec.manifest_path == "git-ws.toml"
+    assert project_spec.groups == Groups()
+    assert project_spec.with_groups == Groups()
 
     with raises(ValueError):
         ProjectSpec(name="name", remote="remote", url="url")
@@ -91,179 +144,139 @@ def test_project_spec():
     with raises(TypeError):
         project_spec.name = "other"
 
+    with raises(ValueError):
+        ProjectSpec(name="name", groups=("-foo"))
+
+    with raises(ValueError):
+        ProjectSpec(name="name", with_groups=("-foo"))
 
-def test_manifest_spec(tmp_path):
+
+def test_manifest():
+    """Test Manifest."""
+    manifest = Manifest()
+    assert not manifest.group_filters
+    assert not manifest.dependencies
+
+    # Immutable
+    with raises(TypeError):
+        manifest.defaults = Defaults()
+
+    with raises(ValueError):
+        Manifest(group_filters=("test",))
+
+
+def test_manifest_spec():
     """Test ManifestSpec."""
     manifest_spec = ManifestSpec()
-    assert manifest_spec.defaults == Defaults()
+    assert manifest_spec.version == "1.0"
     assert not manifest_spec.remotes
+    assert not manifest_spec.group_filters
+    assert manifest_spec.defaults == Defaults()
     assert not manifest_spec.dependencies
 
-    filepath = tmp_path / "manifest.toml"
-    manifest_spec.save(filepath)
-    assert filepath.read_text() == MANIFEST_DEFAULT
+    # Immutable
+    with raises(TypeError):
+        manifest_spec.defaults = Defaults()
+
+    with raises(ValueError):
+        ManifestSpec(group_filters=("test",))
 
 
 def test_manifest_spec_save(tmp_path):
     """Manifest Saving."""
+    filepath = tmp_path / "manifest.toml"
+    ManifestSpec().save(filepath)
+    assert filepath.read_text() == MANIFEST_DEFAULT
+
     manifest_spec = ManifestSpec(
-        remotes=[Remote(name="name")],
-        groups=(Group(name="group", optional=False), Group(name="group2")),
+        version="1.1",
+        remotes=[Remote(name="remote")],
+        group_filters=("+test",),
+        defaults=Defaults(remote="remote"),
         dependencies=(ProjectSpec(name="dep"),),
     )
-    text = """\
-version = "1.0"
-##
-## Welcome to Git Workspace's Manifest. It actually contains 4 parts:
-##
-## * Remotes
-## * Groups
-## * Defaults
-## * Dependencies
-##
-## =========
-##  Remotes
-## =========
-##
-## Remotes just refer to a directory with repositories.
+    filepath = tmp_path / "manifest.toml"
+    manifest_spec.save(filepath)
+    manifest = """\
+version = "1.1"
 ##
-## We support relative paths for dependencies. So, if your dependencies are next
-## to your repository, you might NOT need any remote.
-## In other terms: You only need remotes if your dependencies are located on
-## OTHER servers than your server with this manifest.
+## Git Workspace's Manifest. Please see the documentation at:
 ##
-## Remotes have two attributes:
-## * name: Required. String.
-##         Name of the remote. Any valid string. Must be unique within your
-##         manifest.
-## * url-base: Required. String.
-##             URL Prefix. The project 'name' or 'sub-url' will be appended
-##             later-on.
+## https://git-ws.readthedocs.io/en/latest/manual/manifest.html
 ##
+
+
+# group-filters = ["+test", "-doc", "+feature@path"]
+group-filters = ["+test"]
+
+
 # [[remotes]]
 # name = "myremote"
 # url-base = "https://github.com/myuser"
+
+
+[defaults]
+remote = "remote"
+
+
 [[remotes]]
-name = "name"
+name = "remote"
 
+[[dependencies]]
+name = "dep"
+"""
+    assert filepath.read_text() == manifest
 
-## =========
-##  Groups
-## =========
-##
-## Groups structure dependencies.
-##
-## Groups are optional by default.
-## If a dependency belongs to a group, it becomes optional likewise.
-## Groups can be later on selected/deselected by '+group' or '-group'.
-## An optional group can be selected by '+group',
-## a non-optional group can be deselected by '-group'.
-## Deselection has higher priority than selection.
-##
-## Dependencies can refer to non-existing groups. You do NOT need to specify
-## all used groups.
-##
-## Groups have two attributes:
-## * name: Required. String.
-##         Name of the group. Any valid string. Must be unique within your
-##         manifest.
-## * optional: Optional. Bool. Default is True.
-##             Specifies if the group is optional. Meaning it must be selected
-##             explicitly. Otherwise the dependency is not added by default.
-##
-## The following lines set a group as non-optional.
-# [[groups]]
-# name = "test"
-# optional = false
-[[groups]]
-name = "group"
-optional = false
+    filepath = tmp_path / "empty.toml"
+    filepath.touch()
+    manifest_spec.save(filepath)
+    empty = """\
+version = "1.1"
+group-filters = ["+test"]
 
-[[groups]]
-name = "group2"
+[[remotes]]
+name = "remote"
 
+[defaults]
+remote = "remote"
 
-## ==========
-##  Defaults
-## ==========
+[[dependencies]]
+name = "dep"
+"""
+    assert filepath.read_text() == empty
+
+    filepath = tmp_path / "update.toml"
+    filepath.touch()
+    manifest_spec.save(filepath, update=False)
+    update = """\
+version = "1.1"
 ##
-## The 'defaults' section specifies default values for dependencies.
+## Git Workspace's Manifest. Please see the documentation at:
 ##
-## * remote: Optional. String.
-##           Remote used as default.
-##           The 'remote' MUST be defined in the 'remotes' section above!
-## * revision: Optional. String.
-##             Revision used as default. Tag or Branch.
+## https://git-ws.readthedocs.io/en/latest/manual/manifest.html
 ##
-## NOTE: It is recommended to specify a default revision (i.e. 'main').
-##       If a dependency misses 'revision', GitWS will not take care about
-##       revision handling. This may lead to strange side-effects. You
-##       have been warned.
+
+
+# group-filters = ["+test", "-doc", "+feature@path"]
+group-filters = ["+test"]
+
+
+# [[remotes]]
+# name = "myremote"
+# url-base = "https://github.com/myuser"
+[[remotes]]
+name = "remote"
+
 
 [defaults]
+remote = "remote"
+
 # remote = "myserver"
 # revision = "main"
+# groups = ["+test"]
+# with_groups = ["doc"]
 
 
-## ==============
-##  Dependencies
-## ==============
-##
-## The 'dependencies' section specifies all your git clones you need for your
-## project to operate.
-##
-## A dependency has the following attributes:
-## * name: Required. String.
-##         Just name your dependency. It is recommended to choose a
-##         unique name, but not a must.
-## * remote: Optional. String. Restricted (see RESTRICTIONS below).
-##           Remote Alias.
-##           The 'remote' MUST be defined in the 'remotes' section above!
-##           The 'remote' can also be specified in the 'defaults' section.
-## * sub-url: Optional. String. Default: '../{name}[.git]' (see NOTE1 below).
-##            Relative URL to 'url-base' of your specified 'remote'
-##            OR
-##            Relative URL to the URL of the repository containing this
-##            manifest.
-## * url: Optional. String. Restricted (see RESTRICTIONS below).
-##        Absolute URL to the dependent repository.
-## * revision: Optional. String.
-##             Revision to be checked out.
-##             If this attribute is left blank, GitWS does NOT manage the
-##             dependency revision (see NOTE2 below)!
-##             The 'revision' can also be specified in the 'defaults' section.
-## * path: Optional. String. Default is '{name}'.
-##         Project Filesystem Path. Relative to Workspace Root Directory.
-##         The dependency 'name' is used as default for 'path'.
-##         The 'path' MUST be unique within your manifest.
-## * manifest_path: Optional. String. Default: 'git-ws.toml'.
-##                   Path to manifest.
-##                   Relative to 'path'.
-##                   Avoid changing it! It is just additional effort.
-## * groups: Optional. List of Strings.
-##           Dependency Groups.
-##           Dependencies can be categorized into groups.
-##           Groups are optional by default. See 'groups' section above.
-##
-## NOTE1: 'sub-url' is '../{name}[.git]' by default. Meaning if the dependency
-##        is next to your repository containing this manifest, the dependency
-##        is automatically found.
-##        The '.git' suffix is appended if the repository containing this
-##        manifest uses a '.git' suffix.
-##
-## NOTE2: It is recommended to specify a revision (i.e. 'main') either
-##        explicitly or via the 'default' section.
-##        Without a 'revision' GitWS will not take care about revision
-##        handling. This may lead to strange side-effects.
-##        You have been warned.
-##
-## RESTRICTIONS:
-##
-## * `remote` and `url` are mutually exclusive.
-## * `url` and `sub-url` are likewise mutually exclusive
-## * `sub-url` requires a `remote`.
-##
-##
 ## A full flavored dependency using a 'remote':
 # [[dependencies]]
 # name = "myname"
@@ -287,41 +300,7 @@ def test_manifest_spec_save(tmp_path):
 [[dependencies]]
 name = "dep"
 """
-    filepath = tmp_path / "manifest.toml"
-    manifest_spec.save(filepath)
-    assert filepath.read_text() == text
-
-    # empty one
-    filepath = tmp_path / "empty.toml"
-    filepath.touch()
-    manifest_spec.save(filepath)
-    assert (
-        filepath.read_text()
-        == """\
-[[remotes]]
-name = "name"
-
-[[groups]]
-name = "group"
-optional = false
-
-[[groups]]
-name = "group2"
-
-[[dependencies]]
-name = "dep"
-"""
-    )
-
-    # empty one
-    filepath = tmp_path / "empty.toml"
-    filepath.touch()
-    manifest_spec.save(filepath, update=False)
-    assert filepath.read_text() == text
-
-    # Immutable
-    with raises(TypeError):
-        manifest_spec.defaults = Defaults()
+    assert filepath.read_text() == update
 
 
 def test_manifest_spec_from_data(tmp_path):
@@ -335,10 +314,7 @@ def test_manifest_spec_from_data(tmp_path):
             {"name": "remote2", "url-base": "https://git.example.com/base2"},
             {"name": "remote1", "url-base": "https://git.example.com/base1"},
         ],
-        "groups": [
-            {"name": "foo", "optional": False},
-            {"name": "bar"},
-        ],
+        "group-filters": ["+foo", "-bar"],
         "dependencies": [
             {"name": "dep1", "remote": "remote1", "groups": ["test", "foo"]},
             {"name": "dep2", "path": "dep2dir", "url": "https://git.example.com/base3/dep2.git"},
@@ -351,7 +327,7 @@ def test_manifest_spec_from_data(tmp_path):
         Remote(name="remote2", url_base="https://git.example.com/base2"),
         Remote(name="remote1", url_base="https://git.example.com/base1"),
     )
-    assert manifest_spec.groups == (Group(name="foo", optional=False), Group(name="bar"))
+    assert manifest_spec.group_filters == ("+foo", "-bar")
     assert manifest_spec.dependencies == (
         ProjectSpec(name="dep1", remote="remote1", groups=("test", "foo")),
         ProjectSpec(name="dep2", url="https://git.example.com/base3/dep2.git", path="dep2dir"),
@@ -360,220 +336,8 @@ def test_manifest_spec_from_data(tmp_path):
 
     filepath = tmp_path / "manifest.toml"
     manifest_spec.save(filepath)
-    assert (
-        filepath.read_text()
-        == """version = "1.0"
-##
-## Welcome to Git Workspace's Manifest. It actually contains 4 parts:
-##
-## * Remotes
-## * Groups
-## * Defaults
-## * Dependencies
-##
-## =========
-##  Remotes
-## =========
-##
-## Remotes just refer to a directory with repositories.
-##
-## We support relative paths for dependencies. So, if your dependencies are next
-## to your repository, you might NOT need any remote.
-## In other terms: You only need remotes if your dependencies are located on
-## OTHER servers than your server with this manifest.
-##
-## Remotes have two attributes:
-## * name: Required. String.
-##         Name of the remote. Any valid string. Must be unique within your
-##         manifest.
-## * url-base: Required. String.
-##             URL Prefix. The project 'name' or 'sub-url' will be appended
-##             later-on.
-##
-# [[remotes]]
-# name = "myremote"
-# url-base = "https://github.com/myuser"
-[[remotes]]
-name = "remote2"
-url-base = "https://git.example.com/base2"
-
-[[remotes]]
-name = "remote1"
-url-base = "https://git.example.com/base1"
-
-
-## =========
-##  Groups
-## =========
-##
-## Groups structure dependencies.
-##
-## Groups are optional by default.
-## If a dependency belongs to a group, it becomes optional likewise.
-## Groups can be later on selected/deselected by '+group' or '-group'.
-## An optional group can be selected by '+group',
-## a non-optional group can be deselected by '-group'.
-## Deselection has higher priority than selection.
-##
-## Dependencies can refer to non-existing groups. You do NOT need to specify
-## all used groups.
-##
-## Groups have two attributes:
-## * name: Required. String.
-##         Name of the group. Any valid string. Must be unique within your
-##         manifest.
-## * optional: Optional. Bool. Default is True.
-##             Specifies if the group is optional. Meaning it must be selected
-##             explicitly. Otherwise the dependency is not added by default.
-##
-## The following lines set a group as non-optional.
-# [[groups]]
-# name = "test"
-# optional = false
-[[groups]]
-name = "foo"
-optional = false
-
-[[groups]]
-name = "bar"
-
-
-## ==========
-##  Defaults
-## ==========
-##
-## The 'defaults' section specifies default values for dependencies.
-##
-## * remote: Optional. String.
-##           Remote used as default.
-##           The 'remote' MUST be defined in the 'remotes' section above!
-## * revision: Optional. String.
-##             Revision used as default. Tag or Branch.
-##
-## NOTE: It is recommended to specify a default revision (i.e. 'main').
-##       If a dependency misses 'revision', GitWS will not take care about
-##       revision handling. This may lead to strange side-effects. You
-##       have been warned.
-
-[defaults]
-remote = "remote1"
-revision = "v1.3"
-
-# remote = "myserver"
-# revision = "main"
-
-
-## ==============
-##  Dependencies
-## ==============
-##
-## The 'dependencies' section specifies all your git clones you need for your
-## project to operate.
-##
-## A dependency has the following attributes:
-## * name: Required. String.
-##         Just name your dependency. It is recommended to choose a
-##         unique name, but not a must.
-## * remote: Optional. String. Restricted (see RESTRICTIONS below).
-##           Remote Alias.
-##           The 'remote' MUST be defined in the 'remotes' section above!
-##           The 'remote' can also be specified in the 'defaults' section.
-## * sub-url: Optional. String. Default: '../{name}[.git]' (see NOTE1 below).
-##            Relative URL to 'url-base' of your specified 'remote'
-##            OR
-##            Relative URL to the URL of the repository containing this
-##            manifest.
-## * url: Optional. String. Restricted (see RESTRICTIONS below).
-##        Absolute URL to the dependent repository.
-## * revision: Optional. String.
-##             Revision to be checked out.
-##             If this attribute is left blank, GitWS does NOT manage the
-##             dependency revision (see NOTE2 below)!
-##             The 'revision' can also be specified in the 'defaults' section.
-## * path: Optional. String. Default is '{name}'.
-##         Project Filesystem Path. Relative to Workspace Root Directory.
-##         The dependency 'name' is used as default for 'path'.
-##         The 'path' MUST be unique within your manifest.
-## * manifest_path: Optional. String. Default: 'git-ws.toml'.
-##                   Path to manifest.
-##                   Relative to 'path'.
-##                   Avoid changing it! It is just additional effort.
-## * groups: Optional. List of Strings.
-##           Dependency Groups.
-##           Dependencies can be categorized into groups.
-##           Groups are optional by default. See 'groups' section above.
-##
-## NOTE1: 'sub-url' is '../{name}[.git]' by default. Meaning if the dependency
-##        is next to your repository containing this manifest, the dependency
-##        is automatically found.
-##        The '.git' suffix is appended if the repository containing this
-##        manifest uses a '.git' suffix.
-##
-## NOTE2: It is recommended to specify a revision (i.e. 'main') either
-##        explicitly or via the 'default' section.
-##        Without a 'revision' GitWS will not take care about revision
-##        handling. This may lead to strange side-effects.
-##        You have been warned.
-##
-## RESTRICTIONS:
-##
-## * `remote` and `url` are mutually exclusive.
-## * `url` and `sub-url` are likewise mutually exclusive
-## * `sub-url` requires a `remote`.
-##
-##
-## A full flavored dependency using a 'remote':
-# [[dependencies]]
-# name = "myname"
-# remote = "remote"
-# sub-url = "my.git"
-# revision = "main"
-# path = "mydir"
-# groups = ["group"]
-
-## A full flavored dependency using a 'url':
-# [[dependencies]]
-# name = "myname"
-# url = "https://github.com/myuser/my.git"
-# revision = "main"
-# path = "mydir"
-# groups = ["group"]
-
-## A minimal dependency:
-# [[dependencies]]
-# name = "my"
-[[dependencies]]
-name = "dep1"
-remote = "remote1"
-groups = ["test", "foo"]
-
-[[dependencies]]
-name = "dep2"
-url = "https://git.example.com/base3/dep2.git"
-path = "dep2dir"
-
-[[dependencies]]
-name = "dep3"
-remote = "remote1"
-sub-url = "sub.git"
-revision = "main"
-"""
-    )
     assert ManifestSpec.load(filepath) == manifest_spec
 
-    rdependencies = [Project.from_spec(manifest_spec, project) for project in manifest_spec.dependencies]
-    assert rdependencies == [
-        Project(
-            name="dep1",
-            url="https://git.example.com/base1/dep1",
-            revision="v1.3",
-            path="dep1",
-            groups=(Group(name="test"), Group(name="foo", optional=False)),
-        ),
-        Project(name="dep2", url="https://git.example.com/base3/dep2.git", revision="v1.3", path="dep2dir"),
-        Project(name="dep3", url="https://git.example.com/base1/sub.git", revision="main", path="dep3"),
-    ]
-
 
 def test_manifest_spec_from_other_data():
     """Determine ManifestSpec from Other Data."""
@@ -587,10 +351,11 @@ def test_manifest_spec_from_other_data():
             {"name": "dep3", "remote": "remote1", "sub-url": "sub.git", "revision": "main"},
         ],
     }
-    manifest = ManifestSpec(**data)
-    assert manifest.defaults == Defaults(remote="remote1")
-    assert not manifest.remotes
-    assert manifest.dependencies == (
+    manifest_spec = ManifestSpec(**data)
+    assert manifest_spec.version == "1.0"
+    assert not manifest_spec.remotes
+    assert manifest_spec.defaults == Defaults(remote="remote1")
+    assert manifest_spec.dependencies == (
         ProjectSpec(name="dep1", remote="remote1"),
         ProjectSpec(name="dep2", url="https://git.example.com/base3/dep2.git", path="dep2dir"),
         ProjectSpec(name="dep3", remote="remote1", sub_url="sub.git", revision="main"),
@@ -615,26 +380,23 @@ def test_manifest_spec_missing_remote():
     assert str(exc.value) == "Unknown remote remote1 for project foo"
 
 
-def test_manifest():
+def test_manifest_from_spec():
     """Test Manifest."""
     data = {
         "remotes": [
             {
                 "name": "remote1",
-                "url-base": "url1",
+                "url-base": "file:///repos/url1",
             },
             {
                 "name": "remote2",
-                "url-base": "url2",
+                "url-base": "file:///repos/url2",
             },
         ],
         "defaults": {
             "remote": "remote1",
         },
-        "groups": [
-            {"name": "doc", "optional": True},
-            {"name": "bar"},
-        ],
+        "group-filters": ["-doc", "-bar"],
         "dependencies": [
             {"name": "dep1", "remote": "remote2", "groups": ["test", "doc"]},
             {"name": "dep2", "path": "dep2dir", "url": "https://git.example.com/base3/dep2.git"},
@@ -642,13 +404,13 @@ def test_manifest():
         ],
     }
     manifest_spec = ManifestSpec(**data)
+
     manifest = Manifest.from_spec(manifest_spec)
+    assert manifest.group_filters == ("-doc", "-bar")
     assert manifest.dependencies == (
-        Project(
-            name="dep1", path="dep1", url="url2/dep1", groups=(Group(name="test"), Group(name="doc", optional=True))
-        ),
+        Project(name="dep1", path="dep1", url="file:///repos/url2/dep1", groups=("test", "doc")),
         Project(name="dep2", path="dep2dir", url="https://git.example.com/base3/dep2.git"),
-        Project(name="dep3", path="dep3", url="url1/sub.git", revision="main", groups=(Group(name="test"),)),
+        Project(name="dep3", path="dep3", url="file:///repos/url1/sub.git", revision="main", groups=("test",)),
     )
     assert manifest.path is None
 
@@ -662,49 +424,49 @@ def test_default_url():
         ],
     }
     manifest_spec = ManifestSpec(**data)
+    assert manifest_spec.version == "1.0"
     assert not manifest_spec.remotes
-    assert not manifest_spec.groups
+    assert not manifest_spec.group_filters
+    assert manifest_spec.defaults == Defaults()
     assert manifest_spec.dependencies == (
         ProjectSpec(name="dep1"),
         ProjectSpec(name="dep2"),
     )
 
     manifest = Manifest.from_spec(manifest_spec)
-    assert not manifest.groups
+    assert not manifest.group_filters
     assert manifest.dependencies == (
         Project(name="dep1", path="dep1", url="../dep1"),
         Project(name="dep2", path="dep2", url="../dep2"),
     )
+    assert manifest.path is None
 
-    manifest = Manifest.from_spec(manifest_spec, refurl="https://my.domain.com/repos/main")
-    assert not manifest.groups
+    manifest = Manifest.from_spec(manifest_spec, refurl="https://my.domain.com/repos/main", path="foo")
+    assert not manifest.group_filters
     assert manifest.dependencies == (
         Project(name="dep1", path="dep1", url="https://my.domain.com/repos/dep1"),
         Project(name="dep2", path="dep2", url="https://my.domain.com/repos/dep2"),
     )
+    assert manifest.path == "foo"
 
     manifest = Manifest.from_spec(manifest_spec, refurl="https://my.domain.com/repos/main.git")
-    assert not manifest.groups
+    assert not manifest.group_filters
     assert manifest.dependencies == (
         Project(name="dep1", path="dep1", url="https://my.domain.com/repos/dep1.git"),
         Project(name="dep2", path="dep2", url="https://my.domain.com/repos/dep2.git"),
     )
+    assert manifest.path is None
 
     manifest = Manifest.from_spec(manifest_spec, refurl="https://my.domain.com/repos/main.suffix")
-    assert not manifest.groups
+    assert not manifest.group_filters
     assert manifest.dependencies == (
         Project(name="dep1", path="dep1", url="https://my.domain.com/repos/dep1.suffix"),
         Project(name="dep2", path="dep2", url="https://my.domain.com/repos/dep2.suffix"),
     )
+    assert manifest.path is None
 
 
 def test_remotes_unique():
     """Remote names must be unique."""
     with raises(ValueError):
         ManifestSpec(remotes=(Remote(name="foo", url_base="url"), Remote(name="foo", url_base="url")))
-
-
-def test_groups_unique():
-    """Group names must be unique."""
-    with raises(ValueError):
-        ManifestSpec(groups=(Group(name="foo"), Group(name="foo")))
diff --git a/tests/test_init.py b/tests/test_init.py
index 25e4a7c..d2a4dc7 100644
--- a/tests/test_init.py
+++ b/tests/test_init.py
@@ -21,183 +21,7 @@
 from gitws.const import CONFIG_PATH, INFO_PATH
 
 from .common import MANIFEST_DEFAULT
-from .util import chdir, cli, run
-
-
-def test_cli_nogit(tmp_path):
-    """Init without GIT repo."""
-    main_path = tmp_path / "main"
-    main_path.mkdir(parents=True)
-    with chdir(main_path):
-        assert cli(["init"], exit_code=1) == [
-            "Error: git clone has not been found or initialized yet. Change to your existing git clone or try:",
-            "",
-            "    git init",
-            "",
-            "or:",
-            "",
-            "    git clone",
-            "",
-            "",
-        ]
-
-
-def test_cli_git(tmp_path):
-    """Init with GIT repo."""
-    main_path = tmp_path / "main"
-    main_path.mkdir(parents=True)
-    with chdir(main_path):
-        run(("git", "init"), check=True)
-        assert (main_path / ".git").exists()
-        assert not (tmp_path / ".git-ws").exists()
-
-        assert cli(["init"], exit_code=1) == [
-            "===== main (MAIN) =====",
-            "Error: Manifest has not been found at 'git-ws.toml'. Try:",
-            "",
-            "    git ws manifest create --manifest='git-ws.toml'",
-            "",
-            "",
-        ]
-
-        assert not (tmp_path / ".git-ws").exists()
-
-        assert cli(["manifest", "create"]) == ["Manifest 'git-ws.toml' created.", ""]
-
-        manifest_path = main_path / "git-ws.toml"
-        assert manifest_path.read_text() == MANIFEST_DEFAULT
-
-        assert cli(["init"]) == [
-            "===== main (MAIN) =====",
-            "Workspace initialized at '..'.",
-            "Please continue with:",
-            "",
-            "    git ws update",
-            "",
-            "",
-        ]
-
-        assert cli(["init"], tmp_path=tmp_path, exit_code=1) == [
-            "Error: git workspace has already been initialized at 'TMP' with main repo at 'main'.",
-            "",
-        ]
-
-
-def test_cli_abs_manifest(tmp_path):
-    """Init with GIT repo."""
-    main_path = tmp_path / "main"
-    main_path.mkdir(parents=True)
-    with chdir(main_path):
-        run(("git", "init"), check=True)
-        assert (main_path / ".git").exists()
-        assert not (tmp_path / ".git-ws").exists()
-        assert cli(["manifest", "create"]) == ["Manifest 'git-ws.toml' created.", ""]
-
-        assert cli(["init", "-M", str(main_path / "git-ws.toml")]) == [
-            "===== main (MAIN) =====",
-            "Workspace initialized at '..'.",
-            "Please continue with:",
-            "",
-            "    git ws update",
-            "",
-            "",
-        ]
-
-        assert cli(["init"], tmp_path=tmp_path, exit_code=1) == [
-            "Error: git workspace has already been initialized at 'TMP' with main repo at 'main'.",
-            "",
-        ]
-
-
-def test_cli_git_path(tmp_path):
-    """Init with GIT repo."""
-    main_path = tmp_path / "main"
-    main_path.mkdir(parents=True)
-    with chdir(main_path):
-        run(("git", "init"), check=True)
-        assert (main_path / ".git").exists()
-        assert cli(["manifest", "create"]) == ["Manifest 'git-ws.toml' created.", ""]
-
-    with chdir(tmp_path):
-        assert cli(["init", "main", "add"], exit_code=1) == ["Error: more than one PATH specified", ""]
-
-        assert cli(["init", "main"]) == [
-            "===== main (MAIN) =====",
-            "Workspace initialized at '.'.",
-            "Please continue with:",
-            "",
-            "    git ws update",
-            "",
-            "",
-        ]
-
-        assert cli(["checkout"]) == ["===== main (MAIN) =====", ""]
-
-    assert (
-        (tmp_path / ".git-ws" / "info.toml").read_text()
-        == """\
-# Git Workspace System File. DO NOT EDIT.
-
-main_path = "main"
-"""
-    )
-    assert (
-        (tmp_path / ".git-ws" / "config.toml").read_text()
-        == """\
-manifest_path = "git-ws.toml"
-"""
-    )
-
-
-def test_cli_init_exists(tmp_path):
-    """Init with GIT repo."""
-    main_path = tmp_path / "main"
-    main_path.mkdir(parents=True)
-    with chdir(main_path):
-        run(("git", "init"), check=True)
-        assert (main_path / ".git").exists()
-        assert cli(["manifest", "create"]) == ["Manifest 'git-ws.toml' created.", ""]
-
-        (tmp_path / "something").touch()
-
-        assert cli(["init"], tmp_path=tmp_path, exit_code=1) == [
-            "Error: Workspace '..' is not an empty directory.",
-            "",
-            "Choose an empty directory or use '--force'",
-            "",
-            "",
-        ]
-
-        assert not (tmp_path / ".git-ws").exists()
-        assert cli(["init", "--force"], tmp_path=tmp_path) == [
-            "===== main (MAIN) =====",
-            "Workspace initialized at '..'.",
-            "Please continue with:",
-            "",
-            "    git ws update",
-            "",
-            "",
-        ]
-
-
-def test_cli_git_update(tmp_path):
-    """Init with GIT repo."""
-    main_path = tmp_path / "main"
-    main_path.mkdir(parents=True)
-    with chdir(main_path):
-        run(("git", "init"), check=True)
-        assert (main_path / ".git").exists()
-
-        assert cli(["manifest", "create"]) == ["Manifest 'git-ws.toml' created.", ""]
-
-        manifest_path = main_path / "git-ws.toml"
-        assert manifest_path.read_text() == MANIFEST_DEFAULT
-
-        assert cli(["init", "--update"], tmp_path=tmp_path) == [
-            "===== main (MAIN) =====",
-            "Workspace initialized at '..'.",
-            "",
-        ]
+from .util import chdir, run
 
 
 def test_git(tmp_path):
@@ -235,4 +59,7 @@ def test_git(tmp_path):
 
         rrepo = GitWS.from_path()
         assert rrepo.path == tmp_path
+        assert gws.workspace == rrepo.workspace
+        assert gws.manifest_path == rrepo.manifest_path
+        assert gws.group_filters == rrepo.group_filters
         assert gws == rrepo
diff --git a/tests/test_workspace.py b/tests/test_workspace.py
index 0b4cee3..e5de19d 100644
--- a/tests/test_workspace.py
+++ b/tests/test_workspace.py
@@ -33,7 +33,7 @@ def test_load():
     workspace_path = TESTDATA / "workspace0"
     workspace = Workspace.from_path(workspace_path / "bar" / "sub")
     assert workspace.path == workspace_path
-    assert workspace.info == Info(main_path=Path("bar"), manifest_path="git-ws.toml")
+    assert workspace.info == Info(main_path=Path("bar"))
 
 
 def test_load_uninit(tmp_path):
@@ -50,7 +50,8 @@ def test_init(tmp_path):
         run(("git", "init"), check=True)
         workspace = Workspace.init(tmp_path, main_path, manifest_path=Path("resolved.toml"))
         assert workspace.path == tmp_path
-        assert workspace.info == Info(main_path=Path("main"), manifest_path="resolved.toml")
+        assert workspace.info == Info(main_path=Path("main"))
+        assert workspace.info.main_path == Path("main")
         info_file = workspace.path / INFO_PATH
         assert info_file.read_text().split("\n") == [
             "# Git Workspace System File. DO NOT EDIT.",
@@ -65,7 +66,7 @@ def test_init(tmp_path):
         ]
 
         with raises(InitializedError):
-            Workspace.init(tmp_path, main_path, manifest_path=Path("resolved.toml"))
+            Workspace.init(tmp_path, main_path)
 
 
 def test_outside(tmp_path):
diff --git a/tests/testdata/workspace0/.git-ws/info.toml b/tests/testdata/workspace0/.git-ws/info.toml
index 5af3ca8..8a52701 100644
--- a/tests/testdata/workspace0/.git-ws/info.toml
+++ b/tests/testdata/workspace0/.git-ws/info.toml
@@ -1,2 +1,1 @@
 main_path = "bar"
-manifest_path = "git-ws.toml"
diff --git a/tests/util.py b/tests/util.py
index fca8dee..05a337d 100644
--- a/tests/util.py
+++ b/tests/util.py
@@ -81,3 +81,14 @@ def cli(command, exit_code=0, tmp_path=None):
     output = format_output(result, tmp_path=tmp_path)
     assert result.exit_code == exit_code, (result.exit_code, output)
     return output
+
+
+def check(workspace, name, content=None, exists=True):
+    """Check."""
+    file_path = workspace / name / "data.txt"
+    content = content or name
+    if exists:
+        assert file_path.exists()
+        assert file_path.read_text() == f"{content}"
+    else:
+        assert not file_path.exists()