Repository: iterative/dvc

Issue:
Easier tracking with git by outputing a `git add` command after `dvc run`
Just an idea. I wish dvc would (1) show me what files it created/changed and (2) make it easier to track those files with git. Maybe something like this:
```
➤ dvc run -d infile -o outfile cp infile outfile
Running command:
        cp infile outfile
Saving 'outfile' to cache '.dvc/cache'.
Created 'hardlink': .dvc/cache/87/27026644df14a9006d71b59a6317ae -> outfile
Saving information to 'outfile.dvc'.

To track the changes with git run:

    git add outfile.dvc .gitignore
```
In this example it's pretty obvious what to track, but if you have output files deeper in the folder hierarchy it can be tedious to add these files.

This feature could be useful for `dvc add` and `dvc repro` as well.


Discussion:
Hi @sotte !

Great idea as always! I'm looking into it right now. Will be released with 0.19 till the end of the week.

Thanks,
Ruslan
You folks are amazing!

Resolving patch:
diff --git a/dvc/project.py b/dvc/project.py
--- a/dvc/project.py
+++ b/dvc/project.py
@@ -34,7 +34,7 @@ def __init__(self, root_dir):
         self.dvc_dir = os.path.join(self.root_dir, self.DVC_DIR)
 
         self.config = Config(self.dvc_dir)
-        self.scm = SCM(self.root_dir)
+        self.scm = SCM(self.root_dir, project=self)
         self.lock = Lock(self.dvc_dir)
         # NOTE: storing state and link_state in the repository itself to avoid
         # any possible state corruption in 'shared cache dir' scenario.
@@ -48,10 +48,21 @@ def __init__(self, root_dir):
         self.updater = Updater(self.dvc_dir)
         self.prompt = Prompt()
 
+        self._files_to_git_add = []
+
         self._ignore()
 
         self.updater.check()
 
+    def _remind_to_git_add(self):
+        if len(self._files_to_git_add) == 0:
+            return
+
+        msg = '\nTo track the changes with git run:\n\n'
+        msg += '\tgit add ' + " ".join(self._files_to_git_add)
+
+        self.logger.info(msg)
+
     @staticmethod
     def init(root_dir=os.curdir, no_scm=False, force=False):
         """
@@ -69,10 +80,10 @@ def init(root_dir=os.curdir, no_scm=False, force=False):
         import shutil
         from dvc.scm import SCM, Base
         from dvc.config import Config
+        from dvc.logger import Logger
 
         root_dir = os.path.abspath(root_dir)
         dvc_dir = os.path.join(root_dir, Project.DVC_DIR)
-
         scm = SCM(root_dir)
         if type(scm) == Base and not no_scm:
             msg = "{} is not tracked by any supported scm tool(e.g. git)."
@@ -93,6 +104,8 @@ def init(root_dir=os.curdir, no_scm=False, force=False):
         if scm.ignore_file():
             scm.add([os.path.join(dvc_dir, scm.ignore_file())])
 
+        Logger.info('You can now commit the changes to git.')
+
         return proj
 
     def destroy(self):
@@ -148,6 +161,7 @@ def add(self, fname, recursive=False):
             fnames = [fname]
 
         stages = []
+        self._files_to_git_add = []
         with self.state:
             for f in fnames:
                 stage = Stage.loads(project=self,
@@ -160,6 +174,8 @@ def add(self, fname, recursive=False):
                 stage.dump()
                 stages.append(stage)
 
+        self._remind_to_git_add()
+
         return stages
 
     def remove(self, target, outs_only=False):
@@ -185,6 +201,7 @@ def move(self, from_path, to_path):
                                      [from_path])[0]
 
         found = False
+        self._files_to_git_add = []
         with self.state:
             for stage in self.stages():
                 for out in stage.outs:
@@ -215,6 +232,8 @@ def move(self, from_path, to_path):
 
                 stage.dump()
 
+        self._remind_to_git_add()
+
         if not found:
             msg = 'Unable to find dvcfile with output \'{}\''
             raise DvcException(msg.format(from_path))
@@ -241,12 +260,15 @@ def run(self,
 
         self._check_output_duplication(stage.outs)
 
+        self._files_to_git_add = []
         with self.state:
             if not no_exec:
                 stage.run()
 
         stage.dump()
 
+        self._remind_to_git_add()
+
         return stage
 
     def imp(self, url, out):
@@ -257,10 +279,14 @@ def imp(self, url, out):
 
         self._check_output_duplication(stage.outs)
 
+        self._files_to_git_add = []
         with self.state:
             stage.run()
 
         stage.dump()
+
+        self._remind_to_git_add()
+
         return stage
 
     def _reproduce_stage(self, stages, node, force, dry, interactive):
@@ -298,6 +324,7 @@ def reproduce(self,
             core = config._config[config.SECTION_CORE]
             interactive = core.get(config.SECTION_CORE_INTERACTIVE, False)
 
+        self._files_to_git_add = []
         with self.state:
             if recursive:
                 ret = self._reproduce_stages(G,
@@ -313,6 +340,8 @@ def reproduce(self,
                                             dry,
                                             interactive)
 
+        self._remind_to_git_add()
+
         return ret
 
     def _reproduce_stages(self, G, stages, node, force, dry, interactive):
diff --git a/dvc/scm.py b/dvc/scm.py
--- a/dvc/scm.py
+++ b/dvc/scm.py
@@ -14,8 +14,9 @@ class FileNotInRepoError(DvcException):
 
 
 class Base(object):
-    def __init__(self, root_dir=os.curdir):
-        self.root_dir = os.path.abspath(os.path.realpath(root_dir))
+    def __init__(self, root_dir=os.curdir, project=None):
+        self.project = project
+        self.root_dir = root_dir
 
     @staticmethod
     def is_repo(root_dir):
@@ -78,15 +79,16 @@ class Git(Base):
     GITIGNORE = '.gitignore'
     GIT_DIR = '.git'
 
-    def __init__(self, root_dir=os.curdir):
-        super(Git, self).__init__(root_dir)
+    def __init__(self, root_dir=os.curdir, project=None):
+        super(Git, self).__init__(root_dir, project=project)
 
         import git
         from git.exc import InvalidGitRepositoryError
         try:
             self.repo = git.Repo(root_dir)
         except InvalidGitRepositoryError:
-            raise SCMError('{} is not a git repository'.format(root_dir))
+            msg = '{} is not a git repository'
+            raise SCMError(msg.format(root_dir))
 
         # NOTE: fixing LD_LIBRARY_PATH for binary built by PyInstaller.
         # http://pyinstaller.readthedocs.io/en/stable/runtime-information.html
@@ -136,7 +138,11 @@ def ignore(self, path):
         if len(ignore_list) > 0:
             content = '\n' + content
 
-        open(gitignore, 'a').write(content)
+        with open(gitignore, 'a') as fd:
+            fd.write(content)
+
+        if self.project is not None:
+            self.project._files_to_git_add.append(os.path.relpath(gitignore))
 
     def ignore_remove(self, path):
         entry, gitignore = self._get_gitignore(path)
@@ -152,6 +158,9 @@ def ignore_remove(self, path):
         with open(gitignore, 'w') as fd:
             fd.writelines(filtered)
 
+        if self.project is not None:
+            self.project._files_to_git_add.append(os.path.relpath(gitignore))
+
     def add(self, paths):
         # NOTE: GitPython is not currently able to handle index version >= 3.
         # See https://github.com/iterative/dvc/issues/610 for more details.
@@ -202,8 +211,8 @@ def install(self):
         os.chmod(hook, 0o777)
 
 
-def SCM(root_dir=os.curdir, no_scm=False):
+def SCM(root_dir, no_scm=False, project=None):
     if Git.is_repo(root_dir):
-        return Git(root_dir)
+        return Git(root_dir, project=project)
 
-    return Base(root_dir)
+    return Base(root_dir, project=project)
diff --git a/dvc/stage.py b/dvc/stage.py
--- a/dvc/stage.py
+++ b/dvc/stage.py
@@ -366,6 +366,8 @@ def dump(self, fname=None):
         with open(fname, 'w') as fd:
             yaml.safe_dump(self.dumpd(), fd, default_flow_style=False)
 
+        self.project._files_to_git_add.append(os.path.relpath(fname))
+
     def save(self):
         for dep in self.deps:
             dep.save()


Test patch: