Repository: datalad/datalad-next

Issue:
Consolidate "traverser" from gooey and metalad into a common implementation
This implementation should be layered, to be useful for listing, but also status/diff reporting.

It should also be benchmarked, relative to git-lsdir and and git-ls-tree.

Ping @christian-monch 

Discussion:
mih: > It may well be faulty on `datalad status`' side (I didn't yet look close), but there is a difference in the file type reported for an unstaged typechange

Yes, this is a difference, and it needs to be figured out, how it should be addresses.

`datalad status` reported "would-be" types (only). If a symlink is to an annexed file then it decides that a user would want to not-know that this is actually a symlink (because it is, in some sense, an implementation detail).

`datalad next-status` reports the type as known to Git.

I will likely make that Git-type an explicit property of the result, and keep `type` for backward compatibility. However, the way `datalad status` infers the difference of a symlink from annex vs a symlink for other reasons is expensive. Not sure if I am willing to maintain this behavior entirely.

Patch:
diff --git a/datalad_next/__init__.py b/datalad_next/__init__.py
index 116df89..86b8798 100644
--- a/datalad_next/__init__.py
+++ b/datalad_next/__init__.py
@@ -43,6 +43,10 @@ command_suite = (
             'datalad_next.commands.ls_file_collection', 'LsFileCollection',
             'ls-file-collection',
         ),
+        (
+            'datalad_next.commands.status', 'Status',
+            'next-status', 'next_status',
+        ),
     ]
 )
 
diff --git a/datalad_next/commands/__init__.py b/datalad_next/commands/__init__.py
index 1138be5..c7cfe99 100644
--- a/datalad_next/commands/__init__.py
+++ b/datalad_next/commands/__init__.py
@@ -7,6 +7,14 @@ validation and structured error reporting.
 Beyond that, any further components necessary to implement command are imported
 in this module to offer a one-stop-shop experience. This includes
 ``build_doc``, ``datasetmethod``, and ``eval_results``, among others.
+
+.. currentmodule:: datalad_next.commands
+.. autosummary::
+   :toctree: generated
+
+   CommandResult
+   CommandResultStatus
+   status.StatusResult
 """
 from __future__ import annotations
 
@@ -26,7 +34,10 @@ from datalad_next.constraints.parameter import (
     ParameterConstraintContext,
 )
 from datalad_next.datasets import datasetmethod
-
+from .results import (
+    CommandResult,
+    CommandResultStatus,
+)
 
 class ValidatedInterface(Interface):
     """Alternative base class for commands with uniform parameter validation
diff --git a/datalad_next/commands/results.py b/datalad_next/commands/results.py
new file mode 100644
index 0000000..318db9e
--- /dev/null
+++ b/datalad_next/commands/results.py
@@ -0,0 +1,122 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+import logging
+from pathlib import (
+    Path,
+    PurePath,
+)
+
+from datalad_next.datasets import Dataset
+from datalad_next.exceptions import CapturedException
+
+
+# TODO Could be `StrEnum`, came with PY3.11
+class CommandResultStatus(Enum):
+    """Enumeration of possible statuses of command results
+    """
+    ok = 'ok'
+    notneeded = 'notneeded'
+    impossible = 'impossible'
+    error = 'error'
+
+
+# which status is a success , which is failure
+success_status_map = {
+    'ok': 'success',
+    'notneeded': 'success',
+    'impossible': 'failure',
+    'error': 'failure',
+}
+
+
+# We really want this to be `kw_only=True`, but cannot, because it only
+# came with PY3.10
+# Until this can be enabled, we cannot have additional _required_ properties
+# coming from derived classes. Instead, we have to make any and all
+# additional properties optional (with default None), because also in this
+# base class we do define optional ones (and it makes no sense not to do
+# that either).
+#@dataclass(kw_only=True)
+@dataclass
+class CommandResult:
+    """Base data class for result records emitted by DataLad commands.
+
+    Historically, such results records have taken the form of a Python
+    ``dict``. This class provides some API for its instances to be
+    compatible with legacy code that expects a ``dict``.
+
+    .. seealso::
+
+      https://docs.datalad.org/design/result_records.html
+    """
+    # TODO implement post_init and possibly check for validated of
+    # some arguments (e.g. status is a valid value). Maybe do all of that
+    # conditional on some config flag that could be set during test
+    # execution
+
+    # mandatory as per
+    # http://docs.datalad.org/design/result_records.html#mandatory-fields
+    action: str
+    """A string label identifying which type of operation a result is
+    associated with. Labels must not contain white space. They should be
+    compact, and lower-cases, and use ``_`` (underscore) to separate words in
+    compound labels.
+    """
+    status: CommandResultStatus
+    """This field indicates the nature of a result in terms of four
+    categories, identified by a :class:`CommandResultStatus` value.
+    The result status is used by user communication, but also for decision
+    making on the overall success or failure of a command operation.
+    """
+    path: str | Path
+    """An *absolute* path describing the local entity a result is associated
+    with (the subject of the result record). Paths must be platform-specific
+    (e.g., Windows paths on Windows, and POSIX paths on other operating
+    systems). When a result is about an entity that has no meaningful relation
+    to the local file system (e.g., a URL to be downloaded), the ``path`` value
+    should be determined with respect to the potential impact of the result
+    on any local entity (e.g., a URL downloaded to a local file path, a local
+    dataset modified based on remote information).
+    """
+    # optional
+    # TODO complete documentation of all members
+    message: str | tuple | None = None
+    exception: CapturedException | None = None
+    error_message: str | tuple | None = None
+    type: str | None = None
+    logger: logging.Logger | None = None
+    refds: str | Path | Dataset = None
+
+    # any and all of the code below makes it possible to feed such result
+    # instances through the datalad-core result processing loop (which
+    # expects results to be dicts with string keys and (most) values to
+    # be string only also.
+    def __contains__(self, key: str) -> bool:
+        return hasattr(self, key)
+
+    def __getitem__(self, key: str):
+        return self._stringify4legacy(getattr(self, key))
+
+    def get(self, key, default=None):
+        return self._stringify4legacy(getattr(self, key, default))
+
+    def pop(self, key, default=None):
+        item = getattr(self, key, default)
+        if hasattr(self, key):
+            setattr(self, key, None)
+        return self._stringify4legacy(item)
+
+    def items(self):
+        for k, v in self.__dict__.items():
+            yield k, self._stringify4legacy(v)
+
+    def _stringify4legacy(self, val):
+        if isinstance(val, PurePath):
+            return str(val)
+        elif isinstance(val, Dataset):
+            return val.path
+        elif issubclass(getattr(val, '__class__', None), Enum):
+            return val.value
+        return val
diff --git a/datalad_next/commands/status.py b/datalad_next/commands/status.py
new file mode 100644
index 0000000..8b0f48c
--- /dev/null
+++ b/datalad_next/commands/status.py
@@ -0,0 +1,375 @@
+"""
+"""
+from __future__ import annotations
+
+__docformat__ = 'restructuredtext'
+
+from dataclasses import dataclass
+from enum import Enum
+from logging import getLogger
+from pathlib import Path
+from typing import Generator
+
+from datalad_next.commands import (
+    CommandResult,
+    CommandResultStatus,
+    EnsureCommandParameterization,
+    ValidatedInterface,
+    Parameter,
+    ParameterConstraintContext,
+    build_doc,
+    datasetmethod,
+    eval_results,
+)
+from datalad_next.constraints import (
+    EnsureChoice,
+    WithDescription,
+)
+from datalad_next.constraints.dataset import EnsureDataset
+
+from datalad_next.iter_collections.gitdiff import (
+    GitDiffStatus,
+    GitTreeItemType,
+    GitContainerModificationType,
+)
+from datalad_next.iter_collections.gitstatus import (
+    iter_gitstatus,
+)
+from datalad_next.uis import (
+    ui_switcher as ui,
+    ansi_colors as ac,
+)
+
+lgr = getLogger('datalad.core.local.status')
+
+
+# TODO Could be `StrEnum`, came with PY3.11
+class StatusState(Enum):
+    """Enumeration of possible states of a status command result
+
+    The "state" is the condition of the dataset item being reported
+    on.
+    """
+    clean = 'clean'
+    added = 'added'
+    modified = 'modified'
+    deleted = 'deleted'
+    untracked = 'untracked'
+    unknown = 'unknown'
+
+
+STATE_COLOR_MAP = {
+    StatusState.added: ac.GREEN,
+    StatusState.modified: ac.RED,
+    StatusState.deleted: ac.RED,
+    StatusState.untracked: ac.RED,
+    StatusState.unknown: ac.YELLOW,
+}
+
+
+diffstatus2resultstate_map = {
+    GitDiffStatus.addition: StatusState.added,
+    GitDiffStatus.copy: StatusState.added,
+    GitDiffStatus.deletion: StatusState.deleted,
+    GitDiffStatus.modification: StatusState.modified,
+    GitDiffStatus.rename: StatusState.added,
+    GitDiffStatus.typechange: StatusState.modified,
+    GitDiffStatus.unmerged: StatusState.unknown,
+    GitDiffStatus.unknown: StatusState.unknown,
+    GitDiffStatus.other: StatusState.untracked,
+}
+
+
+# see base class decorator comment for why this is commented out
+#@dataclass(kw_only=True)
+@dataclass
+class StatusResult(CommandResult):
+    # TODO any of the following property are not actually optional
+    # we only have to declare them such for limitations of dataclasses
+    # prior PY3.10 (see kw_only command in base class
+
+    diff_state: GitDiffStatus | None = None
+    """The ``status`` of the underlying ``GitDiffItem``. It is named
+    "_state" to emphasize the conceptual similarity with the legacy
+    property 'state'
+    """
+    gittype: GitTreeItemType | None = None
+    """The ``gittype`` of the underlying ``GitDiffItem``."""
+    prev_gittype: GitTreeItemType | None = None
+    """The ``prev_gittype`` of the underlying ``GitDiffItem``."""
+    modification_types: tuple[GitContainerModificationType] | None = None
+    """Qualifiers for modification types of container-type
+    items (directories, submodules)."""
+
+    @property
+    def state(self) -> StatusState:
+        """A (more or less legacy) simplified representation of the subject
+        state. For a more accurate classification use the ``diff_status``
+        property.
+        """
+        return diffstatus2resultstate_map[self.diff_state]
+
+    # the previous status-implementation did not report plain git-types
+    # we establish a getter to perform this kind of inference/mangling,
+    # when it is needed
+    @property
+    def type(self) -> str | None:
+        """
+        """
+        # TODO this is just a placeholder
+        return self.gittype.value if self.gittype else None
+
+    # we need a setter for this `type`-override stunt
+    @type.setter
+    def type(self, value):
+        self.gittype = value
+
+    @property
+    def prev_type(self) -> str:
+        """
+        """
+        return self.prev_gittype.value if self.prev_gittype else None
+
+    @property
+    def type_src(self) -> str | None:
+        """Backward-compatibility adaptor"""
+        return self.prev_type
+
+
+opt_untracked_values = ('no', 'whole-dir', 'no-empty-dir', 'normal', 'all')
+opt_recursive_values = ('no', 'repository', 'datasets', 'mono')
+opt_eval_subdataset_state_values = ('no', 'commit', 'full')
+
+
+class StatusParamValidator(EnsureCommandParameterization):
+    def __init__(self):
+        super().__init__(
+            param_constraints=dict(
+                # if given, it must also exist
+                dataset=EnsureDataset(installed=True),
+                untracked=EnsureChoice(*opt_untracked_values),
+                recursive=EnsureChoice(*opt_recursive_values),
+                eval_subdataset_state=EnsureChoice(
+                    *opt_eval_subdataset_state_values)
+            ),
+            validate_defaults=('dataset',),
+            joint_constraints={
+                ParameterConstraintContext(('untracked', 'recursive'),
+                                           'option normalization'):
+                self.normalize_options,
+            },
+        )
+
+    def normalize_options(self, **kwargs):
+        if kwargs['untracked'] == 'no':
+            kwargs['untracked'] = None
+        if kwargs['untracked'] == 'normal':
+            kwargs['untracked'] = 'no-empty-dir'
+        if kwargs['recursive'] == 'datasets':
+            kwargs['recursive'] = 'submodules'
+        if kwargs['recursive'] == 'mono':
+            kwargs['recursive'] = 'monolithic'
+        return kwargs
+
+
+@build_doc
+class Status(ValidatedInterface):
+    """Report on the (modification) status of a dataset
+
+    .. note::
+
+        This is a preview of an command implementation aiming to replace
+        the DataLad ``status`` command.
+
+        For now, expect anything here to change again.
+
+    This command provides a report that is roughly identical to that of
+    ``git status``. Running with default parameters yields a report that
+    should look familiar to Git and DataLad users alike, and contain
+    the same information as offered by ``git status``.
+
+    The main difference to ``git status`` are:
+
+    - Support for recursion into submodule. ``git status`` does that too,
+      but the report is limited to the global state of an entire submodule,
+      whereas this command can issue detailed reports in changes inside
+      a submodule (any nesting depth).
+
+    - Support for directory-constrained reporting. Much like ``git status``
+      limits its report to a single repository, this command can optionally
+      limit its report to a single directory and its direct children. In this
+      report subdirectories are considered containers (much like) submodules,
+      and a change summary is provided for them.
+
+    - Support for a "mono" (monolithic repository) report. Unlike a standard
+      recursion into submodules, and checking each of them for changes with
+      respect to the HEAD commit of the worktree, this report compares a
+      submodule with respect to the state recorded in its parent repository.
+      This provides an equally comprehensive status report from the point of
+      view of a queried repository, but does not include a dedicated item on
+      the global state of a submodule. This makes nested hierarchy of
+      repositories appear like a single (mono) repository.
+
+    - Support for "adjusted mode" git-annex repositories. These utilize a
+      managed branch that is repeatedly rewritten, hence is not suitable
+      for tracking within a parent repository. Instead, the underlying
+      "corresponding branch" is used, which contains the equivalent content
+      in an un-adjusted form, persistently. This command detects this condition
+      and automatically check a repositories state against the corresponding
+      branch state.
+
+    *Presently missing/planned features*
+
+    - There is no support for specifying paths (or pathspecs) for constraining
+      the operation to specific dataset parts. This will be added in the
+      future.
+
+    - There is no reporting of git-annex properties, such as tracked file size.
+      It is undetermined whether this will be added in the future. However,
+      even without a dedicated switch, this command has support for
+      datasets (and their submodules) in git-annex's "adjusted mode".
+
+    *Differences to the ``status`` command implementation prior DataLad v2*
+
+    - Like ``git status`` this implementation reports on dataset modification,
+      whereas the previous ``status`` also provided a listing of unchanged
+      dataset content. This is no longer done. Equivalent functionality for
+      listing dataset content is provided by the ``ls_file_collection``
+      command.
+    - The implementation is substantially faster. Depending on the context
+      the speed-up is typically somewhere between 2x and 100x.
+    - The implementation does not suffer from the limitation re type change
+      detection.
+    - Python and CLI API of the command use uniform parameter validation.
+    """
+    # Interface.validate_args() will inspect this dict for the presence of a
+    # validator for particular parameters
+    _validator_ = StatusParamValidator()
+
+    # this is largely here for documentation and CLI parser building
+    _params_ = dict(
+        dataset=Parameter(
+            args=("-d", "--dataset"),
+            doc="""Dataset to be used as a configuration source. Beyond
+            reading configuration items, this command does not interact with
+            the dataset."""),
+        untracked=Parameter(
+            args=('--untracked',),
+            choices=opt_untracked_values,
+            doc="""Determine how untracked content is considered and reported
+            when comparing a revision to the state of the working tree.
+            'no': no untracked content is considered as a change;
+            'normal': untracked files and entire untracked directories are
+            reported as such;
+            'all': report individual files even in fully untracked directories.
+            In addition to these git-status modes,
+            'whole-dir' (like normal, but include empty directories), and
+            'no-empty-dir' (alias for 'normal') are understood."""),
+        recursive=Parameter(
+            args=('-r', '--recursive'),
+            nargs='?',
+            const='datasets',
+            choices=opt_recursive_values,
+            doc="""Mode of recursion for status reporting.
+            With 'no' the report is restricted to a single directory and
+            its direct children.
+            With 'repository', the report comprises all repository content
+            underneath current working directory or root of a given dataset,
+            but is limited to items directly contained in that repository.
+            With 'datasets', the report also comprises any content in any
+            subdatasets. Each subdataset is evaluated against its respective
+            HEAD commit.
+            With 'mono', a report similar to 'datasets' is generated, but
+            any subdataset is evaluate with respect to the state recorded
+            in its parent repository. In contrast to the 'datasets' mode,
+            no report items on a joint submodule are generated.
+            [CMD: If no particular value is given with this option the
+            'datasets' mode is selected. CMD]
+            """),
+        eval_subdataset_state=Parameter(
+            args=("-e", "--eval-subdataset-state",),
+            choices=opt_eval_subdataset_state_values,
+            doc="""Evaluation of subdataset state (modified or untracked
+            content) can be expensive for deep dataset hierarchies
+            as subdataset have to be tested recursively for
+            uncommitted modifications. Setting this option to
+            'no' or 'commit' can substantially boost performance
+            by limiting what is being tested.
+            With 'no' no state is evaluated and subdataset are not
+            investigated for modifications.
+            With 'commit' only a discrepancy of the HEAD commit
+            gitsha of a subdataset and the gitsha recorded in the
+            superdataset's record is evaluated.
+            With 'full' any other modifications are considered
+            too."""),
+    )
+
+    _examples_ = [
+    ]
+
+    @staticmethod
+    @datasetmethod(name="next_status")
+    @eval_results
+    def __call__(
+        # TODO later
+        #path=None,
+        *,
+        dataset=None,
+        # TODO possibly later
+        #annex=None,
+        untracked='normal',
+        recursive='repository',
+        eval_subdataset_state='full',
+    ) -> Generator[StatusResult, None, None] | list[StatusResult]:
+        ds = dataset.ds
+        rootpath = Path.cwd() if dataset.original is None else ds.pathobj
+
+        for item in iter_gitstatus(
+            path=rootpath,
+            untracked=untracked,
+            recursive=recursive,
+            eval_submodule_state=eval_subdataset_state,
+        ):
+            yield StatusResult(
+                action='status',
+                status=CommandResultStatus.ok,
+                path=rootpath / (item.path or item.prev_path),
+                gittype=item.gittype,
+                prev_gittype=item.prev_gittype,
+                diff_state=item.status,
+                modification_types=item.modification_types,
+                refds=ds,
+                logger=lgr,
+            )
+
+    def custom_result_renderer(res, **kwargs):
+        # we are guaranteed to have dataset-arg info through uniform
+        # parameter validation
+        dsarg = kwargs['dataset']
+        rootpath = Path.cwd() if dsarg.original is None else dsarg.ds.pathobj
+        # because we can always determine the root path of the command
+        # execution environment, we can report meaningful relative paths
+        # unconditionally
+        path = res.path.relative_to(rootpath)
+        # collapse item type information across current and previous states
+        type_ = res.type or res.prev_type or ''
+        max_len = len('untracked')
+        state = res.state.value
+        # message format is same as for previous command implementation
+        ui.message(u'{fill}{state}: {path}{type_}{annot}'.format(
+            fill=' ' * max(0, max_len - len(state)),
+            state=ac.color_word(
+                res.state.value,
+                STATE_COLOR_MAP.get(res.state)),
+            path=path,
+            type_=' ({})'.format(ac.color_word(type_, ac.MAGENTA))
+            if type_ else '',
+            annot=f' [{", ".join(q.value for q in res.modification_types)}]'
+            if res.modification_types else '',
+        ))
+
+    @staticmethod
+    def custom_result_summary_renderer(results):
+        # no reports, no changes
+        if len(results) == 0:
+            ui.message("nothing to save, working tree clean")
diff --git a/datalad_next/constraints/base.py b/datalad_next/constraints/base.py
index 2591789..4431050 100644
--- a/datalad_next/constraints/base.py
+++ b/datalad_next/constraints/base.py
@@ -24,6 +24,9 @@ class DatasetParameter:
         self.original = original
         self.ds = ds
 
+    def __repr__(self):
+        return f'{self.__class__.__name__}({self.original}, {self.ds})'
+
 
 class Constraint:
     """Base class for value coercion/validation.
diff --git a/datalad_next/constraints/git.py b/datalad_next/constraints/git.py
index 073c131..7ddc827 100644
--- a/datalad_next/constraints/git.py
+++ b/datalad_next/constraints/git.py
@@ -1,8 +1,11 @@
 """Constraints for Git-related concepts and parameters"""
 from __future__ import annotations
 
-import subprocess
-
+from datalad_next.runners import (
+    CommandError,
+    call_git,
+    call_git_oneline,
+)
 from .base import (
     Constraint,
     DatasetParameter,
@@ -43,7 +46,7 @@ class EnsureGitRefName(Constraint):
             # simple, do here
             self.raise_for(value, 'refname must not be empty')
 
-        cmd = ['git', 'check-ref-format']
+        cmd = ['check-ref-format']
         cmd.append('--allow-onelevel'
                    if self._allow_onelevel
                    else '--no-allow-onelevel')
@@ -55,13 +58,9 @@ class EnsureGitRefName(Constraint):
         cmd.append(value)
 
         try:
-            out = subprocess.run(
-                cmd,
-                text=True,
-                capture_output=True,
-                check=True,
-            )
-        except subprocess.CalledProcessError as e:
+            res = (call_git_oneline
+                   if self._normalize else call_git)(cmd)
+        except CommandError as e:
             self.raise_for(
                 value,
                 'is not a valid refname',
@@ -69,7 +68,7 @@ class EnsureGitRefName(Constraint):
             )
 
         if self._normalize:
-            return out.stdout.strip()
+            return res
         else:
             return value
 
diff --git a/datalad_next/iter_collections/annexworktree.py b/datalad_next/iter_collections/annexworktree.py
index a61d79c..34459c3 100644
--- a/datalad_next/iter_collections/annexworktree.py
+++ b/datalad_next/iter_collections/annexworktree.py
@@ -107,7 +107,7 @@ def iter_annexworktree(
       Path of a directory in a git-annex repository to report on. This
       directory need not be the root directory of the repository, but
       must be part of the repository's work tree.
-    untracked: {'all', 'whole-dir', 'no-empty'} or None, optional
+    untracked: {'all', 'whole-dir', 'no-empty-dir'} or None, optional
       If not ``None``, also reports on untracked work tree content.
       ``all`` reports on any untracked file; ``whole-dir`` yields a single
       report for a directory that is entirely untracked, and not individual
diff --git a/datalad_next/iter_collections/gitdiff.py b/datalad_next/iter_collections/gitdiff.py
index 95f9567..346c53b 100644
--- a/datalad_next/iter_collections/gitdiff.py
+++ b/datalad_next/iter_collections/gitdiff.py
@@ -12,15 +12,18 @@ from pathlib import (
     Path,
     PurePosixPath,
 )
-import subprocess
 from typing import Generator
 
 from datalad_next.consts import PRE_INIT_COMMIT_SHA
-from datalad_next.runners import iter_subproc
+from datalad_next.runners import (
+    CommandError,
+    iter_git_subproc,
+)
 from datalad_next.itertools import (
     decode_bytes,
     itemize,
 )
+from datalad_next.runners import call_git_oneline
 
 from .gittree import (
     GitTreeItem,
@@ -35,14 +38,17 @@ lgr = logging.getLogger('datalad.ext.next.iter_collections.gitdiff')
 class GitDiffStatus(Enum):
     """Enumeration of statuses for diff items
     """
-    addition = 'A'
-    copy = 'C'
-    deletion = 'D'
-    modification = 'M'
-    rename = 'R'
-    typechange = 'T'
-    unmerged = 'U'
-    unknown = 'X'
+    addition = 'addition'
+    copy = 'copy'
+    deletion = 'deletion'
+    modification = 'modification'
+    rename = 'rename'
+    typechange = 'typechange'
+    unmerged = 'unmerged'
+    unknown = 'unknown'
+    # this is a local addition and not defined by git
+    # AKA "untracked"
+    other = 'other'
 
 
 _diffstatus_map = {
@@ -54,9 +60,17 @@ _diffstatus_map = {
     'T': GitDiffStatus.typechange,
     'U': GitDiffStatus.unmerged,
     'X': GitDiffStatus.unknown,
+    'O': GitDiffStatus.other,
 }
 
 
+# TODO Could be `StrEnum`, came with PY3.11
+class GitContainerModificationType(Enum):
+    new_commits = 'new commits'
+    untracked_content = 'untracked content'
+    modified_content = 'modified content'
+
+
 @dataclass
 class GitDiffItem(GitTreeItem):
     """``GitTreeItem`` with "previous" property values given a state comparison
@@ -70,6 +84,9 @@ class GitDiffItem(GitTreeItem):
     """This is the percentage of similarity for copy-status and
     rename-status diff items, and the percentage of dissimilarity
     for modifications."""
+    modification_types: tuple[GitContainerModificationType] | None = None
+    """Qualifiers for modification types of container-type
+    items (directories, submodules)."""
 
     @cached_property
     def prev_path(self) -> PurePosixPath | None:
@@ -79,6 +96,12 @@ class GitDiffItem(GitTreeItem):
             return None
         return PurePosixPath(self.prev_name)
 
+    def add_modification_type(self, value: GitContainerModificationType):
+        if self.modification_types is None:
+            self.modification_types = (value,)
+        else:
+            self.modification_types = (*self.modification_types, value)
+
 
 def iter_gitdiff(
     path: Path,
@@ -89,6 +112,8 @@ def iter_gitdiff(
     find_renames: int | None = None,
     find_copies: int | None = None,
     yield_tree_items: str | None = None,
+    # TODO add documentation
+    eval_submodule_state: str = 'full',
 ) -> Generator[GitDiffItem, None, None]:
     """Report differences between Git tree-ishes or tracked worktree content
 
@@ -180,6 +205,7 @@ def iter_gitdiff(
         find_renames=find_renames,
         find_copies=find_copies,
         yield_tree_items=yield_tree_items,
+        eval_submodule_state=eval_submodule_state,
     )
 
     cmd = _build_cmd(**kwargs)
@@ -229,6 +255,7 @@ def _build_cmd(
     from_treeish, to_treeish,
     recursive, yield_tree_items,
     find_renames, find_copies,
+    eval_submodule_state,
 ) -> list[str]:
     # from   : to   : description
     # ---------------------------
@@ -257,6 +284,16 @@ def _build_cmd(
         # but if that is actually true remains to be tested
         common_args.append(f'--find-copies-harder')
 
+    if eval_submodule_state == 'no':
+        common_args.append('--ignore-submodules=all')
+    elif eval_submodule_state == 'commit':
+        common_args.append('--ignore-submodules=dirty')
+    elif eval_submodule_state == 'full':
+        common_args.append('--ignore-submodules=none')
+    else:
+        raise ValueError(
+            f'unknown submodule evaluation mode {eval_submodule_state!r}')
+
     if from_treeish is None and to_treeish is None:
         raise ValueError(
             'either `from_treeish` or `to_treeish` must not be None')
@@ -275,6 +312,9 @@ def _build_cmd(
             # two tree-ishes given
             cmd.extend((from_treeish, to_treeish))
 
+    # add disambiguation marker for pathspec.
+    # even if we do not pass any, we get simpler error messages from Git
+    cmd.append('--')
     return cmd
 
 
@@ -323,6 +363,19 @@ def _yield_diff_item(
             yield item
             return
         # this is about a present submodule
+        if item.status == GitDiffStatus.modification:
+            if item.gitsha is None:
+                # in 'git diff-index' speak the submodule is "out-of-sync" with
+                # the index: this happens when there are new commits
+                item.add_modification_type(
+                    GitContainerModificationType.new_commits)
+            # TODO we cannot give details for other modification types.
+            # depending on --ignore-submodules a range of situations
+            # could be the case
+            #else:
+            #    # this modification means that "content" is modified
+            #    item.add_modification_type(
+            #        GitContainerModificationType.modified_content)
         if recursive != 'submodules' or yield_tree_items in (
                 'all', 'submodules'):
             # we are instructed to yield it
@@ -386,12 +439,10 @@ def _mangle_item_for_singledir(item, dname, from_treeish, cwd):
     item.gitsha = None
     item.gittype = GitTreeItemType.directory
     try:
-        item.prev_gitsha = subprocess.run(
-            ['git', 'rev-parse', '-q', f'{from_treeish}:./{dname}'],
-            capture_output=True,
-            check=True,
+        item.prev_gitsha = call_git_oneline(
+            ['rev-parse', '-q', f'{from_treeish}:./{dname}'],
             cwd=cwd,
-        ).stdout.decode('utf-8').rstrip()
+        )
         # if we get here, we know that the name was valid in
         # `from_treeish` too
         item.prev_name = dname
@@ -399,7 +450,7 @@ def _mangle_item_for_singledir(item, dname, from_treeish, cwd):
         # a possible type change. For now, we do not go there
         item.prev_gittype = None
         item.status = GitDiffStatus.modification
-    except subprocess.CalledProcessError:
+    except CommandError:
         # the was nothing with this name in `from_treeish`, but now
         # it exists. We compare to the worktree, but not any untracked
         # content -- this means that we likely compare across multiple
@@ -413,14 +464,7 @@ def _mangle_item_for_singledir(item, dname, from_treeish, cwd):
 
 
 def _git_diff_something(path, args):
-    with iter_subproc(
-            [
-                'git',
-                # take whatever is coming in
-                *args,
-            ],
-            cwd=path,
-    ) as r:
+    with iter_git_subproc([*args], cwd=path) as r:
         yield from decode_bytes(
             itemize(
                 r,
diff --git a/datalad_next/iter_collections/gitstatus.py b/datalad_next/iter_collections/gitstatus.py
new file mode 100644
index 0000000..d3f4dd5
--- /dev/null
+++ b/datalad_next/iter_collections/gitstatus.py
@@ -0,0 +1,523 @@
+"""Report on the status of the worktree
+
+The main functionality is provided by the :func:`iter_gitstatus` function.
+"""
+from __future__ import annotations
+
+import logging
+from pathlib import (
+    Path,
+    PurePath,
+)
+from typing import Generator
+
+from datalad_next.runners import (
+    CommandError,
+    iter_git_subproc,
+)
+from datalad_next.itertools import (
+    decode_bytes,
+    itemize,
+)
+from datalad_next.repo_utils import (
+    get_worktree_head,
+    iter_submodules,
+)
+
+from .gitdiff import (
+    GitDiffItem,
+    GitDiffStatus,
+    GitContainerModificationType,
+    iter_gitdiff,
+)
+from .gitworktree import (
+    GitTreeItem,
+    GitTreeItemType,
+    iter_gitworktree,
+    lsfiles_untracked_args,
+    _git_ls_files,
+)
+
+lgr = logging.getLogger('datalad.ext.next.iter_collections.gitstatus')
+
+
+def iter_gitstatus(
+    path: Path,
+    *,
+    untracked: str | None = 'all',
+    recursive: str = 'repository',
+    eval_submodule_state: str = "full",
+) -> Generator[GitDiffItem, None, None]:
+    """
+    Recursion mode 'no'
+
+    This mode limits the reporting to immediate directory items of a given
+    path. This mode is not necessarily faster than a 'repository' recursion.
+    Its primary purpose is the ability to deliver a collapsed report in that
+    subdirectories are treated similar to submodules -- as containers that
+    maybe have modified or untracked content.
+
+    Parameters
+    ----------
+    path: Path
+      Path of a directory in a Git repository to report on. This directory
+      need not be the root directory of the repository, but must be part of
+      the repository. If the directory is not the root directory of a
+      non-bare repository, the iterator is constrained to items underneath
+      that directory.
+    untracked: {'all', 'whole-dir', 'no-empty-dir'} or None, optional
+      If not ``None``, also reports on untracked work tree content.
+      ``all`` reports on any untracked file; ``whole-dir`` yields a single
+      report for a directory that is entirely untracked, and not individual
+      untracked files in it; ``no-empty-dir`` skips any reports on
+      untracked empty directories. Also see ``eval_submodule_state`` for
+      how this parameter is applied in submodule recursion.
+    recursive: {'no', 'repository', 'submodules', 'monolithic'}, optional
+      Behavior for recursion into subtrees. By default (``repository``),
+      all trees within the repository underneath ``path``) are reported,
+      but no tree within submodules. With ``submodules``, recursion includes
+      any submodule that is present. If ``no``, only direct children
+      are reported on.
+    eval_submodule_state: {"no", "commit", "full"}, optional
+      If 'full' (default), the state of a submodule is evaluated by
+      considering all modifications, with the treatment of untracked files
+      determined by `untracked`. If 'commit', the modification check is
+      restricted to comparing the submodule's "HEAD" commit to the one
+      recorded in the superdataset. If 'no', the state of the subdataset is
+      not evaluated. When a git-annex repository in adjusted mode is detected,
+      the reference commit that the worktree is being compared to is the basis
+      of the adjusted branch (i.e., the corresponding branch).
+
+    Yields
+    ------
+    :class:`GitDiffItem`
+      The ``name`` and ``prev_name`` attributes of an item are a ``str`` with
+      the corresponding (relative) path, as reported by Git
+      (in POSIX conventions).
+    """
+    path = Path(path)
+
+    head, corresponding_head = get_worktree_head(path)
+    # TODO it would make sense to always (or optionally) compare against any
+    # existing corresponding_head. This would make the status communicate
+    # anything that has not made it into the corresponding branch yet
+
+    common_args = dict(
+        head=head,
+        path=path,
+        untracked=untracked,
+        eval_submodule_state=eval_submodule_state,
+    )
+
+    if recursive == 'no':
+        yield from _yield_dir_items(**common_args)
+        return
+    elif recursive == 'repository':
+        yield from _yield_repo_items(**common_args)
+    # TODO what we really want is a status that is not against a per-repository
+    # HEAD, but against the commit that is recorded in the parent repository
+    # TODO we need a name for that
+    elif recursive in ('submodules', 'monolithic'):
+        yield from _yield_hierarchy_items(
+            recursion_mode=recursive,
+            **common_args,
+        )
+    else:
+        raise ValueError(f'unknown recursion type {recursive!r}')
+
+
+#
+# status generators for each mode
+#
+
+def _yield_dir_items(
+    *,
+    head: str | None,
+    path: Path,
+    untracked: str | None,
+    eval_submodule_state: str,
+):
+    # potential container items in a directory that need content
+    # investigation
+    container_types = (
+        GitTreeItemType.directory,
+        GitTreeItemType.submodule,
+    )
+    if untracked == 'no':
+        # no need to look at anything other than the diff report
+        dir_items = {}
+    else:
+        # there is no recursion, avoid wasting cycles on listing individual
+        # files in subdirectories
+        untracked = 'whole-dir' if untracked == 'all' else untracked
+        # gather all dierectory items upfront, we subtract the ones reported
+        # modified later and lastly yield all untracked content from them
+        dir_items = {
+            str(item.name): item
+            for item in iter_gitworktree(
+                path,
+                untracked=untracked,
+                recursive='no',
+            )
+        }
+    # diff constrained to direct children
+    for item in ([] if head is None else iter_gitdiff(
+        path,
+        from_treeish='HEAD',
+        # to the worktree
+        to_treeish=None,
+        recursive='no',
+        # TODO trim scope like in repo_items
+        eval_submodule_state=eval_submodule_state,
+    )):
+        if item.status != GitDiffStatus.deletion \
+                and item.gittype in container_types:
+            if item.gittype == GitTreeItemType.submodule:
+                # issue standard submodule container report
+                _eval_submodule(path, item, eval_submodule_state)
+            else:
+                dir_path = path / item.path
+                # this is on a directory. if it appears here, it has
+                # modified content
+                if dir_path.exists():
+                    item.add_modification_type(
+                        GitContainerModificationType.modified_content)
+                    if untracked != 'no' \
+                            and _path_has_untracked(path / item.path):
+                        item.add_modification_type(
+                            GitContainerModificationType.untracked_content)
+                else:
+                    # this directory is gone entirely
+                    item.status = GitDiffStatus.deletion
+                    item.modification_types = None
+            # we dealt with this item completely
+            dir_items.pop(item.name, None)
+        if item.status:
+            yield item
+
+    if untracked == 'no':
+        return
+
+    # yield anything untracked, and inspect remaining containers
+    for dir_item in dir_items.values():
+        if dir_item.gitsha is None and dir_item.gittype is None:
+            # this is untracked
+            yield GitDiffItem(
+                # for homgeneity for report a str-path no matter what
+                name=str(dir_item.name),
+                status=GitDiffStatus.other,
+            )
+        elif dir_item.gittype in container_types:
+            # none of these containers has any modification other than
+            # possibly untracked content
+            item = GitDiffItem(
+                # for homgeneity for report a str-path no matter what
+                name=str(dir_item.name),
+                # this submodule has not been detected as modified
+                # per-commit, assign reported gitsha to pre and post
+                # state
+                gitsha=dir_item.gitsha,
+                prev_gitsha=dir_item.gitsha,
+                gittype=dir_item.gittype,
+                # TODO others?
+            )
+            if item.gittype == GitTreeItemType.submodule:
+                # issue standard submodule container report
+                _eval_submodule(path, item, eval_submodule_state)
+            else:
+                # this is on a directory. if it appears here, it has
+                # no modified content
+                if _path_has_untracked(path / dir_item.path):
+                    item.status = GitDiffStatus.modification
+                    item.add_modification_type(
+                        GitContainerModificationType.untracked_content)
+            if item.status:
+                yield item
+
+
+def _yield_repo_items(
+    *,
+    head: str | None,
+    path: Path,
+    untracked: str | None,
+    eval_submodule_state: str,
+) -> Generator[GitDiffItem, None, None]:
+    """Report status items for a single/whole repsoitory"""
+    present_submodules = {
+        # stringify name for speedy comparison
+        # TODO double-check that comparisons are primarily with
+        # GitDiffItem.name which is str
+        str(item.name): item for item in iter_submodules(path)
+    }
+    # start with a repository-contrained diff against the worktree
+    for item in ([] if head is None else iter_gitdiff(
+        path,
+        from_treeish='HEAD',
+        # to the worktree
+        to_treeish=None,
+        recursive='repository',
+        # we should be able to go cheaper with the submodule evaluation here.
+        # We need to redo some check for adjusted mode, and other cases anyways
+        eval_submodule_state='commit'
+        if eval_submodule_state == 'full' else eval_submodule_state,
+    )):
+        # immediately investigate any submodules that are already
+        # reported modified by Git
+        if item.gittype == GitTreeItemType.submodule:
+            _eval_submodule(path, item, eval_submodule_state)
+            # we dealt with this submodule
+            present_submodules.pop(item.name, None)
+        if item.status:
+            yield item
+
+    # we are not generating a recursive report for submodules, hence
+    # we need to look at ALL submodules for untracked content
+    # `or {}` for the case where we got no submodules, which happens
+    # with `eval_submodule_state == 'no'`
+    for subm_name, subm_item in (present_submodules or {}).items():
+        # none of these submodules has any modification other than
+        # possibly untracked content
+        item = GitDiffItem(
+            # for homgeneity for report a str-path no matter what
+            name=str(subm_item.name),
+            # this submodule has not been detected as modified
+            # per-commit, assign reported gitsha to pre and post
+            # state
+            gitsha=subm_item.gitsha,
+            prev_gitsha=subm_item.gitsha,
+            gittype=subm_item.gittype,
+            # TODO others?
+        )
+        # TODO possibly trim eval_submodule_state
+        _eval_submodule(path, item, eval_submodule_state)
+        if item.status:
+            yield item
+
+    if untracked == 'no':
+        return
+
+    # lastly untracked files of this repo
+    yield from _yield_repo_untracked(path, untracked)
+
+
+def _yield_hierarchy_items(
+    *,
+    head: str | None,
+    path: Path,
+    untracked: str | None,
+    recursion_mode: str,
+    eval_submodule_state: str,
+) -> Generator[GitDiffItem, None, None]:
+    for item in _yield_repo_items(
+        head=head,
+        path=path,
+        untracked=untracked,
+        # TODO do we need to adjust the eval mode here for the diff recmodes?
+        eval_submodule_state=eval_submodule_state,
+    ):
+        # we get to see any submodule item passing through here, and can simply
+        # call this function again for a subpath
+        if item.gittype != GitTreeItemType.submodule:
+            yield item
+            continue
+
+        # submodule recursion
+        # the .path of a GitTreeItem is always POSIX
+        sm_path = path / item.path
+        if recursion_mode == 'submodules':
+            # in this mode, we run the submodule status against it own
+            # worktree head
+            sm_head, _ = get_worktree_head(sm_path)
+            # because this need not cover all possible changes with respect
+            # to the parent repository, we yield an item on the submodule
+            # itself
+            yield item
+        elif recursion_mode == 'monolithic':
+            # in this mode we determine the change of the submodule with
+            # respect to the recorded state in the parent. This is either
+            # the current gitsha, or (if git detected a committed
+            # modification) the previous sha. This way, any further report
+            # on changes a comprehensive from the point of view of the parent
+            # repository, hence no submodule item is emitted
+            sm_head = item.gitsha or item.prev_gitsha
+
+        for i in _yield_hierarchy_items(
+            head=sm_head,
+            path=sm_path,
+            untracked=untracked,
+            # TODO here we could implement handling for a recursion-depth limit
+            recursion_mode=recursion_mode,
+            eval_submodule_state=eval_submodule_state,
+        ):
+            i.name = f'{item.name}/{i.name}'
+            yield i
+
+
+#
+# Helpers
+#
+
+
+def _yield_repo_untracked(
+        path: Path,
+        untracked: str,
+) -> Generator[GitDiffItem, None, None]:
+    """Yield items on all untracked content in a repository"""
+    if untracked is None:
+        return
+    for uf in _git_ls_files(
+        path,
+        *lsfiles_untracked_args[untracked],
+    ):
+        yield GitDiffItem(
+            name=uf,
+            status=GitDiffStatus.other,
+            # it is cheap to discriminate between a directory and anything
+            # else. So let's do that, but not spend the cost of deciding
+            # between files and symlinks
+            gittype=GitTreeItemType.directory if uf.endswith('/') else None
+        )
+
+
+def _path_has_untracked(path: Path) -> bool:
+    """Recursively check for any untracked content (except empty dirs)"""
+    if not path.exists():
+        # cannot possibly have untracked
+        return False
+    for ut in _yield_repo_untracked(
+        path,
+        'no-empty-dir',
+    ):
+        # fast exit on the first detection
+        return True
+    # we need to find all submodules, regardless of mode.
+    # untracked content can also be in a submodule underneath
+    # a directory
+    for subm in iter_submodules(path):
+        if _path_has_untracked(path / subm.path):
+            # fast exit on the first detection
+            return True
+    # only after we saw everything we can say there is nothing
+    return False
+
+
+def _get_submod_worktree_head(path: Path) -> tuple[bool, str | None, bool]:
+    """Returns (submodule exists, SHA | None, adjusted)"""
+    try:
+        HEAD, corresponding_head = get_worktree_head(path)
+    except ValueError:
+        return False, None, False
+
+    adjusted = corresponding_head is not None
+    if adjusted:
+        # this is a git-annex adjusted branch. do the comparison against
+        # its basis. it is not meaningful to track the managed branch in
+        # a superdataset
+        HEAD = corresponding_head
+    with iter_git_subproc(
+        ['rev-parse', '--path-format=relative',
+         '--show-toplevel', HEAD],
+        cwd=path,
+    ) as r:
+        res = tuple(decode_bytes(itemize(r, sep=None, keep_ends=False)))
+        assert len(res) == 2
+        if res[0].startswith('..'):
+            # this is not a report on a submodule at this location
+            return False, None, adjusted
+        else:
+            return True, res[1], adjusted
+
+
+def _eval_submodule(basepath, item, eval_mode) -> None:
+    """In-place amend GitDiffItem submodule item
+
+    It does nothing with ``eval_mode='no'``.
+    """
+    if eval_mode == 'no':
+        return
+
+    item_path = basepath / item.path
+    # get head commit, and whether a submodule is actually present,
+    # and/or in adjusted mode
+    subds_present, head_commit, adjusted = _get_submod_worktree_head(item_path)
+    if not subds_present:
+        return
+
+    if adjusted:
+        _eval_submodule_adjusted(item_path, item, head_commit, eval_mode)
+    else:
+        _eval_submodule_normal(item_path, item, head_commit, eval_mode)
+
+
+def _eval_submodule_normal(item_path, item, head_commit, eval_mode) -> None:
+    if eval_mode == 'full' and item.status is None or (
+        item.modification_types
+        and GitContainerModificationType.new_commits in item.modification_types
+    ):
+        # if new commits have been detected, the diff-implementation is
+        # not able to report "modified content" at the same time, if it
+        # exists. This requires a dedicated inspection, which conincidentally
+        # is identical to the analysis of an adjusted mode submodule.
+        return _eval_submodule_adjusted(
+            item_path, item, head_commit, eval_mode)
+
+    if item.gitsha != head_commit:
+        item.status = GitDiffStatus.modification
+        item.add_modification_type(GitContainerModificationType.new_commits)
+
+    if eval_mode == 'commit':
+        return
+
+    # check for untracked content (recursively)
+    if _path_has_untracked(item_path):
+        item.status = GitDiffStatus.modification
+        item.add_modification_type(
+            GitContainerModificationType.untracked_content)
+
+
+def _eval_submodule_adjusted(item_path, item, head_commit, eval_mode) -> None:
+    # we cannot rely on the diff-report for a submodule in adjusted mode.
+    # git would make the comparison to the adjusted branch HEAD alone.
+    # this would almost always be invalid, because it is not meaningful to
+    # track a commit in an adjusted branch (it goes away).
+    #
+    # instead, we need to:
+    # - check for a change in the corresponding HEAD to the recorded commit
+    #   in the parent repository, consider any change "new commits"
+    # - check for a diff of the worktree to corresponding HEAD, consider
+    #   any such diff a "modified content"
+    # - and lastly check for untracked content
+
+    # start with "no modification"
+    item.status = None
+    item.modification_types = None
+
+    if item.prev_gitsha != head_commit:
+        item.status = GitDiffStatus.modification
+        item.add_modification_type(GitContainerModificationType.new_commits)
+
+    if eval_mode == 'commit':
+        return
+
+    if any(
+        i.status is not None
+        for i in iter_gitdiff(
+            item_path,
+            from_treeish=head_commit,
+            # worktree
+            to_treeish=None,
+            recursive='repository',
+            find_renames=None,
+            find_copies=None,
+            eval_submodule_state='commit',
+        )
+    ):
+        item.status = GitDiffStatus.modification
+        item.add_modification_type(
+            GitContainerModificationType.modified_content)
+
+    # check for untracked content (recursively)
+    if _path_has_untracked(item_path):
+        item.status = GitDiffStatus.modification
+        item.add_modification_type(
+            GitContainerModificationType.untracked_content)
diff --git a/datalad_next/iter_collections/gitworktree.py b/datalad_next/iter_collections/gitworktree.py
index 46ad5c9..bbf06f3 100644
--- a/datalad_next/iter_collections/gitworktree.py
+++ b/datalad_next/iter_collections/gitworktree.py
@@ -101,7 +101,7 @@ def iter_gitworktree(
       Path of a directory in a Git repository to report on. This directory
       need not be the root directory of the repository, but must be part of
       the repository's work tree.
-    untracked: {'all', 'whole-dir', 'no-empty'} or None, optional
+    untracked: {'all', 'whole-dir', 'no-empty-dir'} or None, optional
       If not ``None``, also reports on untracked work tree content.
       ``all`` reports on any untracked file; ``whole-dir`` yields a single
       report for a directory that is entirely untracked, and not individual
@@ -149,7 +149,7 @@ def iter_gitworktree(
         lsfiles_args.extend(lsfiles_untracked_args[untracked])
 
     # helper to handle multi-stage reports by ls-files
-    pending_item = (None,)
+    pending_item = (None, None)
 
     reported_dirs = set()
     _single_dir = recursive == 'no'
@@ -182,7 +182,7 @@ def iter_gitworktree(
                 dir_path = pending_item_path_parts[0]
                 if dir_path in reported_dirs:
                     # we only yield each containing dir once, and only once
-                    pending_item = (ipath,)
+                    pending_item = (ipath, lsfiles_props)
                     continue
                 item = _get_item(
                     path,
@@ -198,12 +198,19 @@ def iter_gitworktree(
                 )
                 yield item
                 reported_dirs.add(dir_path)
-                pending_item = (ipath,)
+                pending_item = (ipath, lsfiles_props)
                 continue
 
             # report on a pending item, this is not a "higher-stage"
             # report by ls-files
-            item = _get_item(path, link_target, fp, *pending_item)
+            item = _get_item(
+                path,
+                link_target,
+                fp,
+                pending_item[0],
+                pending_item[1]['mode'] if pending_item[1] else None,
+                pending_item[1]['gitsha'] if pending_item[1] else None,
+            )
             fp_src = _get_fp_src(fp, path, item)
             if fp_src is None:
                 # nothing to open
@@ -221,13 +228,9 @@ def iter_gitworktree(
             # when no properties were produced, this is a
             # category "other" report (i.e., untracked content)
             # the path is always relative-POSIX
-            pending_item = (ipath,)
+            pending_item = (ipath, None)
         else:
-            pending_item = (
-                ipath,
-                _mode_type_map[lsfiles_props['mode']],
-                lsfiles_props['gitsha']
-            )
+            pending_item = (ipath, lsfiles_props)
         # do not yield immediately, wait for a possible higher-stage
         # report in the next loop iteration
 
@@ -237,9 +240,11 @@ def _get_item(
     link_target: bool,
     fp: bool,
     ipath: PurePosixPath,
-    type: GitTreeItemType | None = None,
+    type: str | GitTreeItemType | None = None,
     gitsha: str | None = None,
 ) -> GitWorktreeItem | GitWorktreeFileSystemItem:
+    if isinstance(type, str):
+        type: GitTreeItemType = _mode_type_map[type]
     if link_target or fp:
         fullpath = basepath / ipath
         item = GitWorktreeFileSystemItem.from_path(
diff --git a/datalad_next/repo_utils/__init__.py b/datalad_next/repo_utils/__init__.py
new file mode 100644
index 0000000..d1160e8
--- /dev/null
+++ b/datalad_next/repo_utils/__init__.py
@@ -0,0 +1,74 @@
+"""Common repository operations
+"""
+from __future__ import annotations
+
+from pathlib import (
+    Path,
+    PurePath,
+)
+from typing import Generator
+
+from datalad_next.exceptions import CapturedException
+from datalad_next.iter_collections.gitworktree import (
+    GitTreeItem,
+    GitTreeItemType,
+    iter_gitworktree,
+)
+from datalad_next.runners import (
+    CommandError,
+    call_git_lines,
+)
+
+
+def iter_submodules(
+    path: Path,
+) -> Generator[GitTreeItem, None, None]:
+    """Given a path, report all submodules of a repository underneath it"""
+    for item in iter_gitworktree(
+        path,
+        untracked=None,
+        link_target=False,
+        fp=False,
+        recursive='repository',
+    ):
+        # exclude non-submodules, or a submodule that was found at
+        # the root path -- which would indicate that the submodule
+        # itself it not around, only its record in the parent
+        if item.gittype == GitTreeItemType.submodule \
+                and item.name != PurePath('.'):
+            yield item
+
+
+def get_worktree_head(
+    path: Path,
+) -> tuple[str | None, str | None]:
+    try:
+        HEAD = call_git_lines(
+            # we add the pathspec disambiguator to get cleaner error messages
+            # (and we only report the first item below, to take it off again)
+            ['rev-parse', '-q', '--symbolic-full-name', 'HEAD', '--'],
+            cwd=path,
+        )[0]
+    except (NotADirectoryError, FileNotFoundError) as e:
+        raise ValueError('path not found') from e
+    except CommandError as e:
+        CapturedException(e)
+        if 'fatal: not a git repository' in e.stderr:
+            raise ValueError(f'no Git repository at {path!r}') from e
+        elif 'fatal: bad revision' in e.stderr:
+            return (None, None)
+        else:
+            # no idea reraise
+            raise
+
+    if HEAD.startswith('refs/heads/adjusted/'):
+        # this is a git-annex adjusted branch. do the comparison against
+        # its basis. it is not meaningful to track the managed branch in
+        # a superdataset
+        return (
+            HEAD,
+            # replace 'refs/heads' with 'refs/basis'
+            f'refs/basis/{HEAD[11:]}',
+        )
+    else:
+        return (HEAD, None)
diff --git a/datalad_next/runners/__init__.py b/datalad_next/runners/__init__.py
index 6c4c2f1..b00b186 100644
--- a/datalad_next/runners/__init__.py
+++ b/datalad_next/runners/__init__.py
@@ -45,6 +45,12 @@ inspired by ``asyncio.SubprocessProtocol``.
 
 from .iter_subproc import (
     iter_subproc,
+)
+from .git import (
+    call_git,
+    call_git_lines,
+    call_git_oneline,
+    call_git_success,
     iter_git_subproc,
 )
 
diff --git a/datalad_next/runners/git.py b/datalad_next/runners/git.py
new file mode 100644
index 0000000..ec158f2
--- /dev/null
+++ b/datalad_next/runners/git.py
@@ -0,0 +1,168 @@
+from __future__ import annotations
+
+from pathlib import Path
+import subprocess
+
+from datalad_next.exceptions import CapturedException
+
+from .iter_subproc import (
+    CommandError,
+    iter_subproc,
+)
+
+
+def _call_git(
+    args: list[str],
+    *,
+    capture_output: bool = False,
+    cwd: Path | None = None,
+    check: bool = False,
+    text: bool | None = None,
+    # TODO
+    #patch_env: dict[str, str] | None = None,
+) -> subprocess.CompletedProcess:
+    """Wrapper around ``subprocess.run`` for calling Git command
+
+    ``args`` is a list of argument for the Git command. This list must not
+    contain the Git executable itself. It will be prepended (unconditionally)
+    to the arguments before passing them on.
+
+    All other argument are pass on to ``subprocess.run()`` verbatim.
+    """
+    # make configurable
+    git_executable = 'git'
+    cmd = [git_executable, *args]
+    try:
+        return subprocess.run(
+            cmd,
+            capture_output=capture_output,
+            cwd=cwd,
+            check=check,
+            text=text,
+        )
+    except subprocess.CalledProcessError as e:
+        # TODO we could support post-error forensics, but some client
+        # might call this knowing that it could fail, and may not
+        # appreciate the slow-down. Add option `expect_fail=False`?
+        #
+        # normalize exception to datalad-wide standard
+        raise CommandError(
+            cmd=cmd,
+            code=e.returncode,
+            stdout=e.stdout,
+            stderr=e.stderr,
+            cwd=cwd,
+        ) from e
+
+
+def call_git(
+    args: list[str],
+    *,
+    cwd: Path | None = None,
+) -> None:
+    """Call git with no output capture, raises on non-zero exit.
+
+    If ``cwd`` is not None, the function changes the working directory to
+    ``cwd`` before executing the command.
+    """
+    _call_git(
+        args,
+        capture_output=False,
+        cwd=cwd,
+        check=True,
+    )
+
+
+def call_git_success(
+    args: list[str],
+    *,
+    cwd: Path | None = None,
+) -> bool:
+    """Call Git for a single line of output.
+
+    ``args`` is a list of arguments for the Git command. This list must not
+    contain the Git executable itself. It will be prepended (unconditionally)
+    to the arguments before passing them on.
+
+    If ``cwd`` is not None, the function changes the working directory to
+    ``cwd`` before executing the command.
+    """
+    try:
+        _call_git(
+            args,
+            capture_output=False,
+            cwd=cwd,
+            check=True,
+        )
+    except CommandError as e:
+        CapturedException(e)
+        return False
+    return True
+
+
+def call_git_lines(
+    args: list[str],
+    *,
+    cwd: Path | None = None,
+) -> bool:
+    """Call Git for any (small) number of lines of output.
+
+    ``args`` is a list of arguments for the Git command. This list must not
+    contain the Git executable itself. It will be prepended (unconditionally)
+    to the arguments before passing them on.
+
+    If ``cwd`` is not None, the function changes the working directory to
+    ``cwd`` before executing the command.
+
+    Raises
+    ------
+    CommandError if the call exits with a non-zero status.
+    """
+    res = _call_git(
+        args,
+        capture_output=True,
+        cwd=cwd,
+        check=True,
+        text=True,
+    )
+    return res.stdout.splitlines()
+
+
+def call_git_oneline(
+    args: list[str],
+    *,
+    cwd: Path | None = None,
+) -> str:
+    """Call git for a single line of output.
+
+    If ``cwd`` is not None, the function changes the working directory to
+    ``cwd`` before executing the command.
+
+    Raises
+    ------
+    CommandError if the call exits with a non-zero status.
+    AssertionError if there is more than one line of output.
+    """
+    lines = call_git_lines(args, cwd=cwd)
+    if len(lines) > 1:
+        raise AssertionError(
+            f"Expected Git {args} to return a single line, but got {lines}"
+        )
+    return lines[0]
+
+
+def iter_git_subproc(
+    args: list[str],
+    **kwargs
+):
+    """``iter_subproc()`` wrapper for calling Git commands
+
+    All argument semantics are identical to those of ``iter_subproc()``,
+    except that ``args`` must not contain the Git binary, but need to be
+    exclusively arguments to it. The respective `git` command/binary is
+    automatically added internally.
+    """
+    cmd = ['git']
+    cmd.extend(args)
+
+    return iter_subproc(cmd, **kwargs)
diff --git a/datalad_next/runners/iter_subproc.py b/datalad_next/runners/iter_subproc.py
index 9b5f2b9..f0c53f5 100644
--- a/datalad_next/runners/iter_subproc.py
+++ b/datalad_next/runners/iter_subproc.py
@@ -14,7 +14,7 @@ from datalad_next.iterable_subprocess.iterable_subprocess import (
 )
 from datalad_next.consts import COPY_BUFSIZE
 
-__all__ = ['iter_subproc', 'iter_git_subproc']
+__all__ = ['iter_subproc']
 
 
 def iter_subproc(
@@ -103,20 +103,3 @@ def iter_subproc(
         chunk_size=chunk_size,
         cwd=cwd,
     )
-
-
-def iter_git_subproc(
-    args: List[str],
-    **kwargs
-):
-    """``iter_subproc()`` wrapper for calling Git commands
-
-    All argument semantics are identical to those of ``iter_subproc()``,
-    except that ``args`` must not contain the Git binary, but need to be
-    exclusively arguments to it. The respective `git` command/binary is
-    automatically added internally.
-    """
-    cmd = ['git']
-    cmd.extend(args)
-
-    return iter_subproc(cmd, **kwargs)
diff --git a/docs/source/api.rst b/docs/source/api.rst
index bd4fa56..f75f34a 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -9,4 +9,5 @@ High-level API commands
    credentials
    download
    ls_file_collection
+   next_status
    tree
diff --git a/docs/source/cmd.rst b/docs/source/cmd.rst
index ce3d3a4..3bae9c8 100644
--- a/docs/source/cmd.rst
+++ b/docs/source/cmd.rst
@@ -8,4 +8,5 @@ Command line reference
    generated/man/datalad-credentials
    generated/man/datalad-download
    generated/man/datalad-ls-file-collection
+   generated/man/datalad-next-status
    generated/man/datalad-tree


Test patch:
diff --git a/datalad_next/commands/tests/test_results.py b/datalad_next/commands/tests/test_results.py
new file mode 100644
index 0000000..0c8329d
--- /dev/null
+++ b/datalad_next/commands/tests/test_results.py
@@ -0,0 +1,61 @@
+from pathlib import (
+    Path,
+    PurePath,
+)
+import pytest
+
+from datalad_next.datasets import Dataset
+
+from ..results import (
+    CommandResult,
+    CommandResultStatus,
+)
+
+
+def test_commandresult():
+    # CommandResult is a plain data class, so there is no much to test
+    # only the partial dict API that is implemented as a compatibility
+    # shim for the datalad core result loop
+    #
+    # we need action, status, and path unconditionally
+    with pytest.raises(TypeError):
+        CommandResult()
+    with pytest.raises(TypeError):
+        CommandResult(action='some')
+    with pytest.raises(TypeError):
+        CommandResult(action='some', status='ok')
+    # no something that works
+    st = CommandResult(
+        action='actionlabel',
+        status=CommandResultStatus.ok,
+        path=PurePath('mypath'),
+        refds=Dataset('myds'),
+    )
+    # we can get a dict with stringified values (for some types)
+    assert dict(st.items()) == {
+        'action': 'actionlabel',
+        'status': 'ok',
+        'path': 'mypath',
+        'message': None,
+        'exception': None,
+        'error_message': None,
+        'type': None,
+        'logger': None,
+        'refds': str(Path.cwd() / 'myds'),
+    }
+    # 'in' works
+    assert 'action' in st
+    assert 'weird' not in st
+    # getitem works, and gives strings
+    assert st['path'] == 'mypath'
+    # same for get
+    assert st.get('path') == 'mypath'
+    assert st.get('weird', 'normal') == 'normal'
+    # 'pop' is emulated by setting to None
+    assert st.pop('path') == 'mypath'
+    assert st.path is None
+    # popping something unknown doesn't blow
+    assert st.pop('weird', 'default') == 'default'
+    # and does not add cruft to the dataclass instance
+    assert not hasattr(st, 'weird')
+
diff --git a/datalad_next/commands/tests/test_status.py b/datalad_next/commands/tests/test_status.py
new file mode 100644
index 0000000..55b91d2
--- /dev/null
+++ b/datalad_next/commands/tests/test_status.py
@@ -0,0 +1,55 @@
+import pytest
+
+from datalad.api import next_status
+
+from datalad_next.constraints.exceptions import (
+    CommandParametrizationError,
+    ParameterConstraintContext,
+)
+from datalad_next.tests.utils import chpwd
+
+from ..status import (
+    opt_eval_subdataset_state_values,
+    opt_recursive_values,
+    opt_untracked_values,
+)
+
+
+def test_status_invalid(tmp_path, datalad_cfg):
+    # we want exhaustive parameter validation (i.e., continue after
+    # first failure), saves some code here
+    datalad_cfg.set('datalad.runtime.parameter-violation',
+                    'raise-at-end',
+                    scope='global')
+    with chpwd(tmp_path):
+        with pytest.raises(CommandParametrizationError) as e:
+            next_status(
+                untracked='weird',
+                recursive='upsidedown',
+                eval_subdataset_state='moonphase',
+            )
+        errors = e.value.errors
+        assert 'no dataset found' in \
+            errors[ParameterConstraintContext(('dataset',))].msg.casefold()
+        for opt in ('untracked', 'recursive', 'eval_subdataset_state'):
+            assert 'is not one of' in \
+                errors[ParameterConstraintContext((opt,))].msg.casefold()
+
+
+def test_status_renderer_smoke(existing_dataset):
+    ds = existing_dataset
+    assert ds.next_status() == []
+    (ds.pathobj / 'untracked').touch()
+    st = ds.next_status()
+    assert len(st) == 1
+
+
+def test_status_clean(existing_dataset, no_result_rendering):
+    ds = existing_dataset
+    ds.create('subds')
+    for recmode in opt_recursive_values:
+        assert [] == ds.next_status(recursive=recmode)
+    for untracked in opt_untracked_values:
+        assert [] == ds.next_status(untracked=untracked)
+    for eval_sm in opt_eval_subdataset_state_values:
+        assert [] == ds.next_status(eval_subdataset_state=eval_sm)
diff --git a/datalad_next/iter_collections/tests/test_itergitdiff.py b/datalad_next/iter_collections/tests/test_itergitdiff.py
index 9daa507..9c63967 100644
--- a/datalad_next/iter_collections/tests/test_itergitdiff.py
+++ b/datalad_next/iter_collections/tests/test_itergitdiff.py
@@ -15,6 +15,9 @@ def test_iter_gitdiff_invalid():
     with pytest.raises(ValueError):
         # no meaningful comparison
         list(iter_gitdiff('.', None, None))
+    with pytest.raises(ValueError):
+        # unsupported eval mode
+        list(iter_gitdiff('.', None, None, eval_submodule_state='weird'))
 
 
 def test_iter_gitdiff_basic(existing_dataset, no_result_rendering):
diff --git a/datalad_next/iter_collections/tests/test_itergitstatus.py b/datalad_next/iter_collections/tests/test_itergitstatus.py
new file mode 100644
index 0000000..9e32f28
--- /dev/null
+++ b/datalad_next/iter_collections/tests/test_itergitstatus.py
@@ -0,0 +1,349 @@
+from itertools import chain
+import pytest
+
+from datalad_next.datasets import Dataset
+from datalad_next.runners import call_git_success
+from datalad_next.tests.utils import rmtree
+
+from ..gitstatus import (
+    GitDiffStatus,
+    GitContainerModificationType,
+    iter_gitstatus,
+)
+
+
+# we make this module-scope, because we use the same complex test case for all
+# tests here and we trust that nothing in here changes that test case
+@pytest.fixture(scope="module")
+def status_playground(tmp_path_factory):
+    """Produces a dataset with various modifications
+
+    ``git status`` will report::
+
+        ❯ git status -uall
+        On branch dl-test-branch
+        Changes to be committed:
+          (use "git restore --staged <file>..." to unstage)
+                new file:   dir_m/file_a
+                new file:   file_a
+
+        Changes not staged for commit:
+          (use "git add/rm <file>..." to update what will be committed)
+          (use "git restore <file>..." to discard changes in working directory)
+          (commit or discard the untracked or modified content in submodules)
+                deleted:    dir_d/file_d
+                deleted:    dir_m/file_d
+                modified:   dir_m/file_m
+                deleted:    dir_sm/sm_d
+                modified:   dir_sm/sm_m (modified content)
+                modified:   dir_sm/sm_mu (modified content, untracked content)
+                modified:   dir_sm/sm_n (new commits)
+                modified:   dir_sm/sm_nm (new commits, modified content)
+                modified:   dir_sm/sm_nmu (new commits, modified content, untracked content)
+                modified:   dir_sm/sm_u (untracked content)
+                deleted:    file_d
+                modified:   file_m
+
+        Untracked files:
+          (use "git add <file>..." to include in what will be committed)
+                dir_m/dir_u/file_u
+                dir_m/file_u
+                dir_u/file_u
+                file_u
+
+    Suffix indicates the ought-to state (multiple possible):
+
+    a - added
+    c - clean
+    d - deleted
+    n - new commits
+    m - modified
+    u - untracked content
+
+    Prefix indicated the item type:
+
+    file - file
+    sm - submodule
+    dir - directory
+    """
+    ds = Dataset(tmp_path_factory.mktemp("status_playground"))
+    ds.create(result_renderer='disabled')
+    ds_dir = ds.pathobj / 'dir_m'
+    ds_dir.mkdir()
+    ds_dir_d = ds.pathobj / 'dir_d'
+    ds_dir_d.mkdir()
+    (ds_dir / 'file_m').touch()
+    (ds.pathobj / 'file_m').touch()
+    dirsm = ds.pathobj / 'dir_sm'
+    dss = {}
+    for smname in (
+        'sm_d', 'sm_c', 'sm_n', 'sm_m', 'sm_nm', 'sm_u', 'sm_mu', 'sm_nmu',
+        'droppedsm_c',
+    ):
+        sds = Dataset(dirsm / smname).create(result_renderer='disabled')
+        # for the plain modification, commit the reference right here
+        if smname in ('sm_m', 'sm_nm', 'sm_mu', 'sm_nmu'):
+            (sds.pathobj / 'file_m').touch()
+        sds.save(to_git=True, result_renderer='disabled')
+        dss[smname] = sds
+    # files in superdataset to be deleted
+    for d in (ds_dir_d, ds_dir, ds.pathobj):
+        (d / 'file_d').touch()
+    dss['.'] = ds
+    dss['dir'] = ds_dir
+    ds.save(to_git=True, result_renderer='disabled')
+    ds.drop(dirsm / 'droppedsm_c', what='datasets', reckless='availability',
+            result_renderer='disabled')
+    # a new commit
+    for smname in ('.', 'sm_n', 'sm_nm', 'sm_nmu'):
+        sds = dss[smname]
+        (sds.pathobj / 'file_c').touch()
+        sds.save(to_git=True, result_renderer='disabled')
+    # modified file
+    for smname in ('.', 'dir', 'sm_m', 'sm_nm', 'sm_mu', 'sm_nmu'):
+        obj = dss[smname]
+        pobj = obj.pathobj if isinstance(obj, Dataset) else obj
+        (pobj / 'file_m').write_text('modify!')
+    # untracked
+    for smname in ('.', 'dir', 'sm_u', 'sm_mu', 'sm_nmu'):
+        obj = dss[smname]
+        pobj = obj.pathobj if isinstance(obj, Dataset) else obj
+        (pobj / 'file_u').touch()
+        (pobj / 'dirempty_u').mkdir()
+        (pobj / 'dir_u').mkdir()
+        (pobj / 'dir_u' / 'file_u').touch()
+    # delete items
+    rmtree(dss['sm_d'].pathobj)
+    rmtree(ds_dir_d)
+    (ds_dir / 'file_d').unlink()
+    (ds.pathobj / 'file_d').unlink()
+    # added items
+    for smname in ('.', 'dir', 'sm_m', 'sm_nm', 'sm_mu', 'sm_nmu'):
+        obj = dss[smname]
+        pobj = obj.pathobj if isinstance(obj, Dataset) else obj
+        (pobj / 'file_a').write_text('added')
+        assert call_git_success(['add', 'file_a'], cwd=pobj)
+
+    yield ds
+
+
+def test_status_homogeneity(status_playground):
+    """Test things that should always be true, no matter the precise
+    parameterization
+
+    A main purpose of this test is also to exercise all (main) code paths.
+    """
+    ds = status_playground
+    for kwargs in (
+        # default
+        dict(path=ds.pathobj),
+        dict(path=ds.pathobj, recursive='no'),
+        dict(path=ds.pathobj, recursive='repository'),
+        dict(path=ds.pathobj, recursive='submodules'),
+        # same as above, but with the submodules in the root
+        dict(path=ds.pathobj / 'dir_sm', recursive='no'),
+        dict(path=ds.pathobj / 'dir_sm', recursive='repository'),
+        dict(path=ds.pathobj / 'dir_sm', recursive='submodules'),
+        # no submodule state
+        dict(path=ds.pathobj, eval_submodule_state='no', recursive='no'),
+        dict(path=ds.pathobj, eval_submodule_state='no', recursive='repository'),
+        dict(path=ds.pathobj, eval_submodule_state='no', recursive='submodules'),
+        # just the commit
+        dict(path=ds.pathobj, eval_submodule_state='commit', recursive='no'),
+        dict(path=ds.pathobj, eval_submodule_state='commit', recursive='repository'),
+        dict(path=ds.pathobj, eval_submodule_state='commit', recursive='submodules'),
+        # without untracked
+        dict(path=ds.pathobj, untracked='no', recursive='no'),
+        dict(path=ds.pathobj, untracked='no', recursive='repository'),
+        dict(path=ds.pathobj, untracked='no', recursive='submodules'),
+        # special untracked modes
+        dict(path=ds.pathobj, untracked='whole-dir', recursive='no'),
+        dict(path=ds.pathobj, untracked='whole-dir', recursive='repository'),
+        dict(path=ds.pathobj, untracked='whole-dir', recursive='submodules'),
+        dict(path=ds.pathobj, untracked='no-empty-dir', recursive='no'),
+        dict(path=ds.pathobj, untracked='no-empty-dir', recursive='repository'),
+        dict(path=ds.pathobj, untracked='no-empty-dir', recursive='submodules'),
+        # call in the mountpoint of a dropped submodule
+        dict(path=ds.pathobj / 'dir_sm' / 'droppedsm_c'),
+    ):
+        st = {item.name: item for item in iter_gitstatus(**kwargs)}
+        # we get no report on anything clean (implicitly also tests
+        # whether all item names are plain strings
+        assert all(not i.name.endswith('_c') for i in st.values())
+
+        # anything untracked is labeled as such
+        assert all(
+            i.status == GitDiffStatus.other
+            # we would not see a submodule modification qualifier when instructed
+            # not to evaluate a submodule
+            or kwargs.get('eval_submodule_state') in ('no', 'commit')
+            or GitContainerModificationType.untracked_content in i.modification_types
+            for i in st.values()
+            if 'u' in i.path.name.split('_')[1]
+        )
+
+        # anything modified is labeled as such
+        assert all(
+            i.status == GitDiffStatus.modification
+            for i in st.values()
+            if 'm' in i.path.name.split('_')[1]
+        )
+
+        # anything deleted is labeled as such
+        assert all(
+            i.status == GitDiffStatus.deletion
+            for i in st.values()
+            if 'd' in i.path.name.split('_')[1]
+        )
+
+
+def test_status_invalid_params(existing_dataset):
+    ds = existing_dataset
+    with pytest.raises(ValueError):
+        list(iter_gitstatus(ds.pathobj, recursive='fromspace'))
+
+
+test_cases_repository_recursion = [
+    {'name': 'file_a', 'status': GitDiffStatus.addition},
+    {'name': 'dir_m/file_a', 'status': GitDiffStatus.addition},
+    {'name': 'file_u', 'status': GitDiffStatus.other},
+    {'name': 'dir_u/file_u', 'status': GitDiffStatus.other},
+    {'name': 'dir_m/file_u', 'status': GitDiffStatus.other},
+    {'name': 'dir_m/dir_u/file_u', 'status': GitDiffStatus.other},
+    {'name': 'file_d', 'status': GitDiffStatus.deletion},
+    {'name': 'dir_d/file_d', 'status': GitDiffStatus.deletion},
+    {'name': 'dir_m/file_d', 'status': GitDiffStatus.deletion},
+    {'name': 'file_m', 'status': GitDiffStatus.modification},
+    {'name': 'dir_m/file_m', 'status': GitDiffStatus.modification},
+    {'name': 'dir_sm/sm_d', 'status': GitDiffStatus.deletion},
+    {'name': 'dir_sm/sm_n', 'status': GitDiffStatus.modification,
+     'qual': (GitContainerModificationType.new_commits,)},
+    {'name': 'dir_sm/sm_m', 'status': GitDiffStatus.modification,
+     'qual': (GitContainerModificationType.modified_content,)},
+    {'name': 'dir_sm/sm_nm', 'status': GitDiffStatus.modification,
+     'qual': (GitContainerModificationType.modified_content,
+              GitContainerModificationType.new_commits)},
+    {'name': 'dir_sm/sm_nmu', 'status': GitDiffStatus.modification,
+     'qual': (GitContainerModificationType.modified_content,
+              GitContainerModificationType.untracked_content,
+              GitContainerModificationType.new_commits)},
+    {'name': 'dir_sm/sm_u', 'status': GitDiffStatus.modification,
+     'qual': (GitContainerModificationType.untracked_content,)},
+    {'name': 'dir_sm/sm_mu', 'status': GitDiffStatus.modification,
+     'qual': (GitContainerModificationType.modified_content,
+              GitContainerModificationType.untracked_content)},
+]
+
+test_cases_submodule_recursion = [
+    {'name': 'dir_sm/sm_m/file_a', 'status': GitDiffStatus.addition},
+    {'name': 'dir_sm/sm_nm/file_a', 'status': GitDiffStatus.addition},
+    {'name': 'dir_sm/sm_mu/file_a', 'status': GitDiffStatus.addition},
+    {'name': 'dir_sm/sm_nmu/file_a', 'status': GitDiffStatus.addition},
+    {'name': 'dir_sm/sm_m/file_m', 'status': GitDiffStatus.modification},
+    {'name': 'dir_sm/sm_mu/file_m', 'status': GitDiffStatus.modification},
+    {'name': 'dir_sm/sm_nmu/file_m', 'status': GitDiffStatus.modification},
+    {'name': 'dir_sm/sm_u/file_u', 'status': GitDiffStatus.other},
+    {'name': 'dir_sm/sm_mu/file_u', 'status': GitDiffStatus.other},
+    {'name': 'dir_sm/sm_nmu/file_u', 'status': GitDiffStatus.other},
+    {'name': 'dir_sm/sm_u/dir_u/file_u', 'status': GitDiffStatus.other},
+    {'name': 'dir_sm/sm_mu/dir_u/file_u', 'status': GitDiffStatus.other},
+    {'name': 'dir_sm/sm_nmu/dir_u/file_u', 'status': GitDiffStatus.other},
+]
+
+
+def _assert_testcases(st, tc):
+    for c in tc:
+        assert st[c['name']].status == c['status']
+        mod_types = st[c['name']].modification_types
+        if 'qual' in c:
+            assert set(mod_types) == set(c['qual'])
+        else:
+            assert mod_types is None
+
+
+def test_status_vs_git(status_playground):
+    """Implements a comparison against how git-status behaved when
+    the test was written  (see fixture docstring)
+    """
+    st = {
+        item.name: item
+        for item in iter_gitstatus(
+            path=status_playground.pathobj, recursive='repository',
+            eval_submodule_state='full', untracked='all',
+        )
+    }
+    _assert_testcases(st, test_cases_repository_recursion)
+
+
+def test_status_norec(status_playground):
+    st = {
+        item.name: item
+        for item in iter_gitstatus(
+            path=status_playground.pathobj, recursive='no',
+            eval_submodule_state='full', untracked='all',
+        )
+    }
+    test_cases = [
+        {'name': 'file_a', 'status': GitDiffStatus.addition},
+        {'name': 'dir_d', 'status': GitDiffStatus.deletion},
+        {'name': 'dir_m', 'status': GitDiffStatus.modification,
+         'qual': (GitContainerModificationType.modified_content,
+                  GitContainerModificationType.untracked_content)},
+        {'name': 'dir_sm', 'status': GitDiffStatus.modification,
+         'qual': (GitContainerModificationType.modified_content,
+                  GitContainerModificationType.untracked_content)},
+        {'name': 'file_d', 'status': GitDiffStatus.deletion},
+        {'name': 'file_m', 'status': GitDiffStatus.modification},
+        {'name': 'dir_u', 'status': GitDiffStatus.other},
+        {'name': 'file_u', 'status': GitDiffStatus.other},
+    ]
+    _assert_testcases(st, test_cases)
+
+
+def test_status_smrec(status_playground):
+    st = {
+        item.name: item
+        for item in iter_gitstatus(
+            path=status_playground.pathobj, recursive='submodules',
+            eval_submodule_state='full', untracked='all',
+        )
+    }
+    # in this mode we expect ALL results of a 'repository' mode recursion,
+    # including the submodule-type items, plus additional ones from within
+    # the submodules
+    _assert_testcases(st, chain(test_cases_repository_recursion,
+                                test_cases_submodule_recursion))
+
+
+def test_status_monorec(status_playground):
+    st = {
+        item.name: item
+        for item in iter_gitstatus(
+            path=status_playground.pathobj, recursive='monolithic',
+            eval_submodule_state='full', untracked='all',
+        )
+    }
+    # in this mode we expect ALL results of a 'repository' mode recursion,
+    # including the submodule-type items, plus additional ones from within
+    # the submodules
+    _assert_testcases(
+        st,
+        # repository and recursive test cases, minus any direct submodule
+        # items
+        [c for c in chain(test_cases_repository_recursion,
+                          test_cases_submodule_recursion)
+         if not c['name'].split('/')[-1].split('_')[0] == 'sm'])
+
+
+def test_status_gitinit(tmp_path):
+    # initialize a fresh git repo, but make no commits
+    assert call_git_success(['init'], cwd=tmp_path)
+    for recmode in ('no', 'repository', 'submodules'):
+        assert [] == list(iter_gitstatus(tmp_path, recursive=recmode))
+    # untracked reporting must be working normal
+    (tmp_path / 'untracked').touch()
+    for recmode in ('no', 'repository', 'submodules'):
+        res = list(iter_gitstatus(tmp_path, recursive=recmode))
+        assert len(res) == 1
+        assert res[0].name == 'untracked'
+        assert res[0].status == GitDiffStatus.other
diff --git a/datalad_next/repo_utils/tests/__init__.py b/datalad_next/repo_utils/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/datalad_next/repo_utils/tests/test_head.py b/datalad_next/repo_utils/tests/test_head.py
new file mode 100644
index 0000000..5530f44
--- /dev/null
+++ b/datalad_next/repo_utils/tests/test_head.py
@@ -0,0 +1,35 @@
+import pytest
+
+from datalad_next.runners import call_git
+
+from .. import get_worktree_head
+
+
+def test_get_worktree_head(tmp_path, existing_dataset):
+    ds = existing_dataset
+
+    with pytest.raises(ValueError) as e:
+        get_worktree_head(tmp_path / 'IDONOTEXISTONTHEFILESYSTEM')
+    assert str(e.value) == 'path not found'
+
+    norepo = tmp_path / 'norepo'
+    norepo.mkdir()
+    with pytest.raises(ValueError) as e:
+        get_worktree_head(norepo)
+    assert str(e.value) == f'no Git repository at {norepo!r}'
+
+    reponohead = tmp_path / 'reponohead'
+    reponohead.mkdir()
+    call_git(['init'], cwd=reponohead)
+    assert (None, None) == get_worktree_head(reponohead)
+
+    # and actual repo with a commit
+    head, chead = get_worktree_head(ds.pathobj)
+    # we always get a HEAD
+    # we always get fullname symbolic info
+    assert head.startswith('refs/heads/')
+    if chead is not None:
+        # there is a corresponding head, and we get it as the
+        # git-annex 'basis' ref
+        assert head.startswith('refs/heads/adjusted/')
+        assert chead.startswith('refs/basis/')
diff --git a/datalad_next/runners/tests/test_git.py b/datalad_next/runners/tests/test_git.py
new file mode 100644
index 0000000..66eed6e
--- /dev/null
+++ b/datalad_next/runners/tests/test_git.py
@@ -0,0 +1,43 @@
+import pytest
+
+from ..git import (
+    CommandError,
+    call_git,
+    call_git_lines,
+    call_git_oneline,
+    call_git_success,
+    iter_git_subproc,
+)
+
+
+def test_call_git():
+    # smoke test
+    call_git(['--version'])
+    # raises properly
+    with pytest.raises(CommandError):
+        call_git(['notacommand'])
+
+
+def test_call_git_success():
+    assert call_git_success(['--version'])
+    assert not call_git_success(['notacommand'])
+
+
+def test_call_git_lines():
+    lines = call_git_lines(['--version'])
+    assert len(lines) == 1
+    assert lines[0].startswith('git version')
+
+
+def test_call_git_oneline():
+    line = call_git_oneline(['--version'])
+    assert line.startswith('git version')
+    with pytest.raises(AssertionError):
+        # TODO may not yield multiple lines on all systems
+        call_git_oneline(['config', '-l'])
+
+
+def test_iter_git_subproc():
+    # just a smoke test that 'git' gets prepended
+    with iter_git_subproc(['--version']) as g:
+        assert list(g)