diff --git a/datalad_next/__init__.py b/datalad_next/__init__.py index 116df896c..86b879839 100644 --- a/datalad_next/__init__.py +++ b/datalad_next/__init__.py @@ -43,6 +43,10 @@ '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 36de84564..c7cfe99b5 100644 --- a/datalad_next/commands/__init__.py +++ b/datalad_next/commands/__init__.py @@ -14,6 +14,7 @@ CommandResult CommandResultStatus + status.StatusResult """ from __future__ import annotations diff --git a/datalad_next/commands/status.py b/datalad_next/commands/status.py new file mode 100644 index 000000000..e672add10 --- /dev/null +++ b/datalad_next/commands/status.py @@ -0,0 +1,298 @@ +""" +""" +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: + """ + """ + # 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', 'submodules', 'datasets') +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' + return kwargs + + +@build_doc +class Status(ValidatedInterface): + """The is a preview of an upcoming command implementation to replace + the DataLad ``status`` command. + + For now expect anything here to change again. + """ + # 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 reported when + comparing a revision to the state of the working tree. + 'no': no untracked content is reported; + '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="some"), + eval_subdataset_state=Parameter( + args=("-e", "--eval-subdataset-state",), + choices=opt_eval_subdataset_state_values, + doc="""Evaluation of subdataset state (clean vs. + modified) 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 result records do not + qualify the nature of a modifcation. + 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 modification is considered + too."""), + ) + + _examples_ = [ + ] + + @staticmethod + @datasetmethod(name="next_status") + @eval_results + def __call__( + # TODO later + #path=None, + *, + dataset=None, + # TODO later + #annex=None, + untracked='normal', + recursive='repository', + # TODO this is needed for all recursion modes + # it would be necessary to traverse the full subtree + # underneath any reported submodule, in order to be able to report + # on the potential presence of untracked content + # + # for all recursion modes we would need to add support for comparing + # the HEAD commit of a submodule with the subproject commit in the + # parent, too + # + 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 '', + )) diff --git a/datalad_next/iter_collections/gitstatus.py b/datalad_next/iter_collections/gitstatus.py index 66d6f78f6..57bb04b3e 100644 --- a/datalad_next/iter_collections/gitstatus.py +++ b/datalad_next/iter_collections/gitstatus.py @@ -11,13 +11,24 @@ ) from typing import Generator +from datalad_next.runners import ( + CommandError, + iter_git_subproc, +) +from datalad_next.itertools import ( + decode_bytes, + itemize, +) + from .gitdiff import ( GitDiffItem, GitDiffStatus, - GitTreeItemType, + GitContainerModificationType, iter_gitdiff, ) from .gitworktree import ( + GitTreeItem, + GitTreeItemType, iter_gitworktree, lsfiles_untracked_args, _git_ls_files, @@ -31,9 +42,18 @@ def iter_gitstatus( *, untracked: str | None = 'all', recursive: str = 'repository', + eval_submodule_state: str = "full", yield_tree_items: str | None = None, ) -> 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 @@ -54,6 +74,13 @@ def iter_gitstatus( 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. yield_tree_items: {'submodules', 'directories', 'all', None}, optional Whether to yield an item on type of subtree that will also be recursed into. For example, a submodule item, when submodule recursion is @@ -71,64 +98,224 @@ def iter_gitstatus( """ path = Path(path) - if untracked is None: - # we can delegate all of this - yield from iter_gitdiff( + if recursive == 'no': + yield from _yield_dir_items( path, - from_treeish='HEAD', - # to the worktree - to_treeish=None, - recursive=recursive, - yield_tree_items=yield_tree_items, + untracked, + eval_submodule_state, ) return - # limit to within-repo, at most - recmode = 'repository' if recursive == 'submodules' else recursive + # we only build the list of submodules now, because for recursion='no' it + # is not sufficient, because subdirectories are considered containers in + # the same fashion + present_submodules = None + if eval_submodule_state == 'full' or recursive == 'submodules': + present_submodules = { + # stringify name for speedy comparison + # TODO double-check that comparisons are primarly with + # GitDiffItem.name which is str + str(item.name): item for item in _yield_repo_submodules(path) + } - # we always start with a repository-contrained diff against the worktree - # tracked content + if recursive == 'repository': + yield from _yield_repo_items( + path, + untracked, + # TODO tailor which items to yield, based on whether this + # function is called with later recursion or not + yield_tree_items, + eval_submodule_state, + present_submodules, + ) + elif recursive == 'submodules': + yield from _yield_hierarchy_items( + path, + untracked, + # TODO tailor which items to yield, based on whether this + # function is called with later recursion or not + yield_tree_items, + eval_submodule_state, + present_submodules, + ) + else: + # TODO better exception + raise + + +# +# status generators for each mode +# + +def _yield_dir_items( + path: Path, + untracked: str, + 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 contrained to direct children for item in iter_gitdiff( path, from_treeish='HEAD', # to the worktree to_treeish=None, - recursive=recmode, - yield_tree_items=yield_tree_items, + recursive='no', + yield_tree_items='all', ): - # TODO when recursive==submodules, do not yield present - # items of present submodules unless yield_tree_items says so + if item.gittype in container_types: + 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 + # modified content + item.add_modification_type( + GitContainerModificationType.modified_content) + if untracked != 'no' and _path_has_untracked(path / item.path): + item.add_modification_type( + GitContainerModificationType.untracked_content) + # we dealt with this item completely + if dir_items: + dir_items.pop(item.name) yield item - # now untracked files of this repo - assert untracked is not None - yield from _yield_repo_untracked(path, untracked) - - if recursive != 'submodules': - # all other modes of recursion have been dealt with + if untracked == 'no': return - # at this point, we know we need to recurse into submodule, and we still - # have to report on untracked files -> scan the worktree - for item in iter_gitworktree( + # 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), + 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( + path: Path, + untracked: str | None, + yield_tree_items: str | None, + eval_submodule_state: str, + present_submodules: dict[str, GitTreeItem], +) -> Generator[GitDiffItem, None, None]: + """Report status items for a single/whole repsoitory""" + # start with a repository-contrained diff against the worktree + for item in iter_gitdiff( path, - untracked=None, - link_target=False, - fp=False, - # singledir mode has been ruled out above, - # we need to find all submodules + from_treeish='HEAD', + # to the worktree + to_treeish=None, recursive='repository', + # this only container-type item we can have in this mode are + # submodules + yield_tree_items='submodules', ): - if item.gittype != GitTreeItemType.submodule \ - or item.name == PurePosixPath('.'): - # either this is no submodule, 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 + # 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) + yield item + + if untracked == 'no': + return + + # we are not generating a recursive report for submodules, hence + # we need to look at ALL submodules for untracked content + for subm_name, subm_item in present_submodules.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), + gitsha=subm_item.gitsha, + gittype=subm_item.gittype, + # TODO others? + ) + _eval_submodule(path, item, eval_submodule_state) + if item.status: + yield item + + # lastly untracked files of this repo + yield from _yield_repo_untracked(path, untracked) + + +def _yield_hierarchy_items( + path: Path, + untracked: str | None, + yield_tree_items: str | None, + eval_submodule_state: str, + present_submodules: dict[str, GitTreeItem], +) -> Generator[GitDiffItem, None, None]: + for item in _yield_repo_items( + path, + untracked, + # TODO make sure we get the submodule items out that we must act on + yield_tree_items, + # TODO tailor based on yield_tree_items + eval_submodule_state, + present_submodules, + ): + # 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 + + # TODO decision on where to yield the submodule item itself + for i in iter_gitstatus( # the .path of a GitTreeItem is always POSIX path=path / item.path, untracked=untracked, + # TODO here we could implement handling for a recursion-depth limit recursive='submodules', yield_tree_items=yield_tree_items, ): @@ -136,7 +323,34 @@ def iter_gitstatus( yield i -def _yield_repo_untracked(path, untracked): +# +# Helpers +# + +def _yield_repo_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 != PurePosixPath('.'): + yield item + + +def _yield_repo_untracked( + path: Path, + untracked: str, +) -> Generator[GitDiffItem, None, None]: + """Yield items on all untracked content in a repository""" for uf in _git_ls_files( path, *lsfiles_untracked_args[untracked], @@ -145,3 +359,81 @@ def _yield_repo_untracked(path, untracked): name=uf, status=GitDiffStatus.other, ) + + +def _path_has_untracked(path: Path) -> bool: + """Recursively check for any untracked content (except empty dirs)""" + 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 _yield_repo_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_subm_head(path: Path) -> tuple[bool, str | None]: + """Returns (submodule exists, SHA | None)""" + # TODO instead of always HEAD, it could be made aware of + # corresponding branches + try: + 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 + else: + return True, res[1] + except CommandError: + return False, None + + +def _eval_submodule(basepath, item, eval_mode) -> None: + """In-place amend GitDiffItem submodule item + """ + # TODO implement + # + # eval_mode is the switch of matching `status --eval-subdataset-state` + # + # Given a discovered submodule, this helpers needs to + # - check the `gitsha` against the HEAD commit of the local submodule + # repository, and adjust the `status` if needed + # - check recursively for untracked content within the submodule + # + # For the check against the HEAD commit, we could detected adjusted + # branches and compare against the corresponding branch! + # + if eval_mode == 'no': + return + + # check for tracked modifications + item_path = basepath / item.path + subds_present, head_commit = _get_subm_head(item_path) + if not subds_present: + return + + 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) 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 000000000..514b310db --- /dev/null +++ b/datalad_next/iter_collections/tests/test_itergitstatus.py @@ -0,0 +1,111 @@ +import pytest + +from datalad_next.datasets import Dataset + +from ..gitstatus import 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 not staged for commit: + (use "git add ..." to update what will be committed) + (use "git restore ..." to discard changes in working directory) + (commit or discard the untracked or modified content in submodules) + modified: dir/file_m + 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) + modified: file_m + + Untracked files: + (use "git add ..." to include in what will be committed) + dir/dir_u/file_u + dir/file_u + dir_u/file_u + file_u + + Suffix indicates the ought-to state (multiple possible): + + c - clean + 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' + ds_dir.mkdir() + (ds_dir / 'file_m').touch() + (ds.pathobj / 'file_m').touch() + dirsm = ds.pathobj / 'dir_sm' + dss = {} + for smname in ( + 'sm_c', 'sm_n', 'sm_m', 'sm_nm', 'sm_u', 'sm_mu', 'sm_nmu', + ): + 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 + dss['.'] = ds + dss['dir'] = ds_dir + ds.save(to_git=True, 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_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() + + yield ds + + +def test_status_homogeneity(status_playground): + """Test things that should always be true, no matter the precise + parameterization""" + ds = status_playground + for kwargs in ( + # default + dict(), + dict(recursive='no'), + dict(recursive='repository'), + dict(recursive='submodules'), + ): + st = { + item.name: item for item in iter_gitstatus( + ds.pathobj, **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()) + # breakpoint() diff --git a/docs/source/api.rst b/docs/source/api.rst index bd4fa567c..f75f34a2b 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 ce3d3a4c3..3bae9c82c 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