#4716 Add the file history feature
Merged 4 years ago by pingou. Opened 4 years ago by pingou.

file modified
+31 -2
@@ -28,10 +28,12 @@ 

      return tuple([int(i) for i in pygit2.__version__.split(".")])

  

  

- def run_command(command):

+ def run_command(command, cwd=None):

      _log.info("Running command: %s", command)

      try:

-         out = subprocess.check_output(command, stderr=subprocess.STDOUT)

+         out = subprocess.check_output(

+             command, stderr=subprocess.STDOUT, cwd=cwd

+         ).decode("utf-8")

          _log.info("   command ran successfully")

          _log.debug("Output: %s" % out)

      except subprocess.CalledProcessError as err:
@@ -44,6 +46,7 @@ 

          raise pagure.exceptions.PagureException(

              "Did not manage to rebase this pull-request"

          )

+     return out

  

  

  class PagureRepo(pygit2.Repository):
@@ -127,3 +130,29 @@ 

                          % (pygit2.GIT_MERGE_ANALYSIS_NORMAL)

                      )

                      raise AssertionError("Unknown merge analysis result")

+ 

+     @staticmethod

+     def log(path, log_options=None, target=None, fromref=None):

+         """ Run git log with the specified options at the specified target.

+ 

+         This method runs the system's `git log` command since pygit2 doesn't

+         offer us the possibility to do this via them.

+ 

+         :kwarg log_options: options to pass to git log

+         :type log_options: list or None

+         :kwarg target: the target of the git log command, can be a ref, a

+             file or nothing

+         :type path_to: str or None

+         :kwarg fromref: a reference/commit to use when generating the log

+         :type path_to: str or None

+         :

+         """

+         cmd = ["git", "log"]

+         if log_options:

+             cmd.extend(log_options)

+         if fromref:

+             cmd.append(fromref)

+         if target:

+             cmd.extend(["--", target])

+ 

+         return run_command(cmd, cwd=path)

@@ -143,6 +143,14 @@ 

        {% endif %}

  

        <a class="btn btn-secondary btn-sm" href="{{ url_for(

+                     'ui_ns.view_history_file',

+                     repo=repo.name,

+                     username=username,

+                     namespace=repo.namespace,

+                     identifier=branchname,

+                     filename=filename) | unicode }}" title="View git log for this file">History</a>

+ 

+       <a class="btn btn-secondary btn-sm" href="{{ url_for(

          'ui_ns.view_raw_file',

          repo=repo.name,

          username=username,

@@ -163,6 +163,14 @@ 

                      filename=filename) | unicode }}" title="View git blame">Blame</a>

  

                  <a class="btn btn-secondary btn-sm" href="{{ url_for(

+                     'ui_ns.view_history_file',

+                     repo=repo.name,

+                     username=username,

+                     namespace=repo.namespace,

+                     identifier=branchname,

+                     filename=filename) | unicode }}" title="View git log for this file">History</a>

+ 

+                 <a class="btn btn-secondary btn-sm" href="{{ url_for(

                      'ui_ns.view_raw_file',

                      repo=repo.name,

                      username=username,

@@ -0,0 +1,173 @@ 

+ {% extends "repo_master.html" %}

+ 

+ {% block title %}File history - {{ repo.fullname }}{% endblock %}

+ {% set tag = "home" %}

+ 

+ {% block header %}

+ <link  nonce="{{ g.nonce }}" rel="stylesheet" href="{{

+   url_for('static', filename='vendor/highlight.js/styles/github.css') }}?version={{ g.version}}"/>

+ 

+ <style nonce="{{ g.nonce }}">

+   .hljs {

+     background: #fff;

+   }

+ </style>

+ {% endblock %}

+ 

+ {% block repo %}

+   <div class="row m-b-1">

+     <div class="col-sm-6">

+     <h3>

+       History {{ filename }}

+     </h3>

+     </div>

+ 

+     <div class="col-sm-6">

+       <div class="float-right">

+       {% if branchname %}

+         <div class="btn-group">

+           <button type="button" class="btn btn-outline-light border-secondary text-dark btn-sm dropdown-toggle"

+                   data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">

+                   <span class="fa fa-random fa-fw"></span> Branch: <span class="font-weight-bold">{{ branchname }}</span>

+           </button>

+           <div class="dropdown-menu dropdown-menu-right">

+             {% for branch in g.branches %}

+               {% if origin == 'view_tree' %}

+                 <a class="dropdown-item" href="{{ url_for(

+                     'ui_ns.view_tree',

+                     repo=repo.name,

+                     username=username,

+                     namespace=repo.namespace,

+                     identifier=branch) }}">

+                   {{ branch }}

+                 </a>

+               {% elif origin == 'view_file' %}

+                 <a class="dropdown-item" href="{{ url_for(

+                     'ui_ns.view_file',

+                     repo=repo.name,

+                     username=username,

+                     namespace=repo.namespace,

+                     identifier=branch,

+                     filename=filename | unicode ) }}">

+                   {{ branch }}

+                 </a>

+               {% endif %}

+             {% endfor %}

+           </div>

+         </div>

+       {% endif %}

+     </div>

+   </div>

+ 

+   </div>

+     <div class="card">

+       <div class="card-header">

+         <ol class="breadcrumb p-0 bg-transparent mb-0">

+           <li>

+             <a href="{{ url_for('ui_ns.view_tree',

+                 repo=repo.name,

+                 username=username,

+                 namespace=repo.namespace,

+                 identifier=branchname)

+           }}">

+               <span class="fa fa-random">

+               </span>&nbsp; {{ branchname }}

+             </a>

+           </li>

+         {% set path = '' %}

+         {% for file in filename.split('/') %}

+           {% if loop.first %}

+           {% set path = file %}

+           {% else %}

+           {% set path = path + '/' + file %}

+           {% endif %}

+           {% if loop.index != loop.length %}<li><a

+           href="{{ url_for('ui_ns.view_file',

+                 repo=repo.name,

+                 username=username,

+                 namespace=repo.namespace,

+                 identifier=branchname,

+                 filename=path | unicode)}}">

+             <span class="fa fa-folder"></span>&nbsp; {{ file }}</a>

+           </li>

+           {% elif file %}

+           <li class="active">

+             <span class="fa {% if output_type == 'tree' %}fa-folder{% else %}fa-file{% endif %}">

+             </span>&nbsp; {{ file }}

+           </li>

+           {% endif %}

+         {% endfor %}

+         </ol>

+       </div>

+ 

+ {% if log %}

+   <div class="card-block p-a-0">

+     <div class="bg-light border pr-2">

+ 

+           <div class="list-group my-2">

+               {% for line in log %}

+               {% set commit = g.repo_obj[line[0]] %}

+               <div class="list-group-item " id="c_{{ commit.hex }}">

+                 <div class="row align-items-center">

+                   <div class="col">

+                     <a href="{{ url_for('ui_ns.view_commit',

+                       repo=repo.name,

+                       username=username,

+                       namespace=repo.namespace,

+                       commitid=commit.hex, branch=branchname) }}"

+                       class="notblue">

+                       <strong>{{ commit.message.split('\n')[0] }}</strong>

+                     </a>

+                     <div>

+                     {{commit.author|author2user_commits(

+                       link=url_for('ui_ns.view_commits',

+                           repo=repo.name,

+                           branchname=branchname,

+                           username=username,

+                           namespace=repo.namespace,

+                           author=commit.author.email),

+                       cssclass="notblue")|safe}}

+                       <span class="commitdate"

+                       title="{{ commit.commit_time|format_ts }}"> &bull;

+                     {{ commit.commit_time|humanize }}</span>&nbsp;&nbsp;

+                       <span id="commit-actions"></span>

+                     </div>

+                   </div>

+                   <div class="col-xs-auto pr-3 text-right">

+                     <div class="btn-group">

+                       <a href="{{ url_for('ui_ns.view_commit',

+                         repo=repo.name,

+                         username=username,

+                         namespace=repo.namespace,

+                         commitid=commit.hex, branch=branchname) }}"

+                         class="btn btn-outline-primary font-weight-bold commithash" id="c_{{ commit.hex }}">

+                         <code>{{ commit.hex|short }}</code>

+                       </a>

+                       <a class="btn btn-outline-primary font-weight-bold" href="{{ url_for(

+                         'ui_ns.view_tree', username=username, namespace=repo.namespace,

+                         repo=repo.name, identifier=commit.hex) }}"><span class="fa fa-file-code-o fa-fw"></span></a>

+                     </div>

+                   </div>

+                 </div>

+               </div>

+             {% endfor %}

+           </div>

+ 

+     </div>

+   </div>

+ {% else %}

+ No history found for this file in this repository

+ {% endif %}

+  </div> <!-- end .card-->

+ 

+ {% endblock %}

+ 

+ {% block jscripts %}

+ {{ super() }}

+ 

+ <script type="text/javascript" nonce="{{ g.nonce }}">

+ $(document).ready(function () {

+   $('.fork_project_btn').click($("[name=fork_project]").submit);

+ });

+ </script>

+ {% endblock %}

file modified
+44
@@ -805,6 +805,50 @@ 

      )

  

  

+ @UI_NS.route("/<repo>/history/<path:filename>")

+ @UI_NS.route("/<namespace>/<repo>/history/<path:filename>")

+ @UI_NS.route("/fork/<username>/<repo>/history/<path:filename>")

+ @UI_NS.route("/fork/<username>/<namespace>/<repo>/history/<path:filename>")

+ def view_history_file(repo, filename, username=None, namespace=None):

+     """ Displays the history of a file or a tree for the specified repo.

+     """

+     repo = flask.g.repo

+     repo_obj = flask.g.repo_obj

+ 

+     branchname = flask.request.args.get("identifier")

+ 

+     if repo_obj.is_empty:

+         flask.abort(404, description="Empty repo cannot have a file")

+ 

+     try:

+         log = pagure.lib.repo.PagureRepo.log(

+             flask.g.reponame,

+             log_options=["--pretty=oneline", "--abbrev-commit"],

+             target=filename,

+             fromref=branchname,

+         )

+         if log.strip():

+             log = [l.split(" ", 1) for l in log.strip().split("\n")]

+         else:

+             log = []

+     except Exception:

+         log = []

+     if not log:

+         flask.abort(400, description="No history could be found for this file")

+ 

+     return flask.render_template(

+         "file_history.html",

+         select="tree",

+         repo=repo,

+         origin="view_file",

+         username=username,

+         filename=filename,

+         branchname=branchname,

+         output_type="history",

+         log=log,

+     )

+ 

+ 

  @UI_NS.route("/<repo>/c/<commitid>/")

  @UI_NS.route("/<repo>/c/<commitid>")

  @UI_NS.route("/<namespace>/<repo>/c/<commitid>/")

@@ -0,0 +1,246 @@ 

+ # -*- coding: utf-8 -*-

+ 

+ """

+ Authors:

+   Pierre-Yves Chibon <pingou@pingoured.fr>

+ 

+ """

+ 

+ from __future__ import unicode_literals, absolute_import

+ 

+ import re

+ import sys

+ import os

+ import pygit2

+ 

+ sys.path.insert(

+     0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")

+ )

+ 

+ import tests

+ import pagure.lib.model

+ 

+ 

+ class PagureFlaskRepoViewHistoryFileSimpletests(tests.Modeltests):

+     """ Tests for view_history_file endpoint of the flask pagure app """

+ 

+     def test_view_history_file_no_project(self):

+         """ Test the view_history_file endpoint """

+         output = self.app.get("/foo/history/sources")

+         # No project registered in the DB

+         self.assertEqual(output.status_code, 404)

+         output_text = output.get_data(as_text=True)

+         self.assertIn(

+             "<title>Page not found :'( - Pagure</title>", output_text

+         )

+         self.assertIn("<h2>Page not found (404)</h2>", output_text)

+         self.assertIn("<p>Project not found</p>", output_text)

+ 

+     def test_view_history_file_no_git_repo(self):

+         """ Test the view_history_file endpoint """

+         tests.create_projects(self.session)

+ 

+         output = self.app.get("/test/history/sources")

+         # No git repo associated

+         self.assertEqual(output.status_code, 404)

+ 

+     def test_view_history_file_no_git_content(self):

+         """ Test the view_history_file endpoint """

+         tests.create_projects(self.session)

+         tests.create_projects_git(os.path.join(self.path, "repos"), bare=True)

+ 

+         output = self.app.get("/test/history/sources")

+         # project and associated repo, but no file

+         self.assertEqual(output.status_code, 404)

+         output_text = output.get_data(as_text=True)

+         self.assertIn(

+             "<title>Page not found :'( - Pagure</title>", output_text

+         )

+         self.assertIn("<h2>Page not found (404)</h2>", output_text)

+         self.assertIn("<p>Empty repo cannot have a file</p>", output_text)

+ 

+ 

+ class PagureFlaskRepoViewHistoryFiletests(tests.Modeltests):

+     """ Tests for view_history_file endpoint of the flask pagure app """

+ 

+     def setUp(self):

+         """ Set up the environment, ran before every tests. """

+         super(PagureFlaskRepoViewHistoryFiletests, self).setUp()

+         self.regex = re.compile(r' <div class="list-group-item " id="c_')

+         tests.create_projects(self.session)

+         tests.create_projects_git(os.path.join(self.path, "repos"), bare=True)

+ 

+         # Add some content to the git repo

+         tests.add_content_to_git(

+             os.path.join(self.path, "repos", "test.git"),

+             message="initial commit",

+         )

+         tests.add_content_to_git(

+             os.path.join(self.path, "repos", "test.git"), message="foo"

+         )

+         tests.add_content_to_git(

+             os.path.join(self.path, "repos", "test.git"),

+             branch="feature",

+             content="bar",

+             message="bar",

+             author=("Aritz Author", "aritz@authors.tld"),

+         )

+ 

+     def test_view_history_file_default_branch_master(self):

+         """ Test the view_history_file endpoint """

+         output = self.app.get("/test/history/sources")

+         self.assertEqual(output.status_code, 200)

+         output_text = output.get_data(as_text=True)

+         self.assertIn("<strong>foo</strong>", output_text)

+         data = self.regex.findall(output_text)

+         self.assertEqual(len(data), 2)

+ 

+     def test_view_history_file_default_branch_non_master(self):

+         """ Test the view_history_file endpoint """

+         repo = pygit2.Repository(os.path.join(self.path, "repos", "test.git"))

+         reference = repo.lookup_reference("refs/heads/feature").resolve()

+         repo.set_head(reference.name)

+         output = self.app.get("/test/history/sources")

+         self.assertEqual(output.status_code, 200)

+         output_text = output.get_data(as_text=True)

+         self.assertIn("<strong>bar</strong>", output_text)

+         data = self.regex.findall(output_text)

+         self.assertEqual(len(data), 3)

+ 

+     def test_view_history_file_on_commit(self):

+         """ Test the view_history_file endpoint """

+         repo_obj = pygit2.Repository(

+             os.path.join(self.path, "repos", "test.git")

+         )

+         commit = repo_obj[repo_obj.head.target]

+         parent = commit.parents[0].oid.hex

+ 

+         output = self.app.get(

+             "/test/history/sources?identifier={}".format(parent)

+         )

+         self.assertEqual(output.status_code, 200)

+         output_text = output.get_data(as_text=True)

+         self.assertIn("<strong>initial commit</strong>", output_text)

+         data = self.regex.findall(output_text)

+         self.assertEqual(len(data), 1)

+ 

+     def test_view_history_file_on_branch(self):

+         """ Test the view_history_file endpoint """

+         output = self.app.get("/test/history/sources?identifier=feature")

+         self.assertEqual(output.status_code, 200)

+         output_text = output.get_data(as_text=True)

+         self.assertIn("<strong>bar</strong>", output_text)

+         data = self.regex.findall(output_text)

+         self.assertEqual(len(data), 3)

+ 

+     def test_view_history_file_on_tag(self):

+         """ Test the view_history_file endpoint """

+         # set a tag on the head's parent commit

+         repo_obj = pygit2.Repository(

+             os.path.join(self.path, "repos", "test.git")

+         )

+         commit = repo_obj[repo_obj.head.target]

+         parent = commit.parents[0].oid.hex

+         tagger = pygit2.Signature("Alice Doe", "adoe@example.com", 12347, 0)

+         repo_obj.create_tag(

+             "v1.0", parent, pygit2.GIT_OBJ_COMMIT, tagger, "Release v1.0"

+         )

+ 

+         output = self.app.get("/test/history/sources?identifier=v1.0")

+         self.assertEqual(output.status_code, 200)

+         output_text = output.get_data(as_text=True)

+         self.assertIn("<strong>initial commit</strong>", output_text)

+         data = self.regex.findall(output_text)

+         self.assertEqual(len(data), 1)

+ 

+     def test_view_history_file_binary(self):

+         """ Test the view_history_file endpoint """

+         # Add binary content

+         tests.add_binary_git_repo(

+             os.path.join(self.path, "repos", "test.git"), "test.jpg"

+         )

+         output = self.app.get("/test/history/test.jpg")

+         self.assertEqual(output.status_code, 200)

+         output_text = output.get_data(as_text=True)

+         self.assertIn("<strong>Add a fake image file</strong>", output_text)

+ 

+     def test_view_history_file_non_ascii_name(self):

+         """ Test the view_history_file endpoint """

+         tests.add_commit_git_repo(

+             os.path.join(self.path, "repos", "test.git"),

+             ncommits=1,

+             filename="Šource",

+         )

+         output = self.app.get("/test/history/Šource")

+         self.assertEqual(output.status_code, 200)

+         output_text = output.get_data(as_text=True)

+         self.assertEqual(

+             output.headers["Content-Type"].lower(), "text/html; charset=utf-8"

+         )

+         self.assertIn("</span>&nbsp; Šource", output_text)

+         self.assertIn("<strong>Add row 0 to Šource file</strong>", output_text)

+ 

+     def test_view_history_file_fork_of_a_fork(self):

+         """ Test the view_history_file endpoint """

+         item = pagure.lib.model.Project(

+             user_id=1,  # pingou

+             name="test3",

+             description="test project #3",

+             is_fork=True,

+             parent_id=1,

+             hook_token="aaabbbppp",

+         )

+         self.session.add(item)

+         self.session.commit()

+ 

+         tests.add_content_git_repo(

+             os.path.join(self.path, "repos", "forks", "pingou", "test3.git")

+         )

+         tests.add_readme_git_repo(

+             os.path.join(self.path, "repos", "forks", "pingou", "test3.git")

+         )

+         tests.add_commit_git_repo(

+             os.path.join(self.path, "repos", "forks", "pingou", "test3.git"),

+             ncommits=10,

+         )

+         tests.add_content_to_git(

+             os.path.join(self.path, "repos", "forks", "pingou", "test3.git"),

+             content="✨☃🍰☃✨",

and the penguin? :p

+         )

+ 

+         output = self.app.get("/fork/pingou/test3/history/sources")

+         self.assertEqual(output.status_code, 200)

+         output_text = output.get_data(as_text=True)

+         self.assertIn(

+             "<strong>Add row 2 to sources file</strong>", output_text

+         )

+ 

+     def test_view_history_file_no_file(self):

+         """ Test the view_history_file endpoint """

+         output = self.app.get("/test/history/foofile")

+         self.assertEqual(output.status_code, 400)

+         output_text = output.get_data(as_text=True)

+         self.assertIn("No history could be found for this file", output_text)

+ 

+     def test_view_history_file_folder(self):

+         """ Test the view_history_file endpoint """

+         tests.add_commit_git_repo(

+             os.path.join(self.path, "repos", "test.git/folder1"),

+             ncommits=1,

+             filename="sources",

+         )

+         output = self.app.get("/test/history/folder1")

+         self.assertEqual(output.status_code, 400)

+         output_text = output.get_data(as_text=True)

+         self.assertIn("No history could be found for this file", output_text)

+ 

+     def test_view_history_file_unborn_head_no_identifier(self):

+         repo_obj = pygit2.Repository(

+             os.path.join(self.path, "repos", "test.git")

+         )

+         repo_obj.set_head("refs/heads/unexistent")

+ 

+         output = self.app.get("/test/history/sources")

+         self.assertEqual(output.status_code, 400)

+         output_text = output.get_data(as_text=True)

+         self.assertIn("No history could be found for this file", output_text)

This still requires some tests, but early feedback welcome :)

@pingou could we have a screenshot too?

6 new commits added

  • Add unit-tests for the view file's history feature
  • Add a link to the file's history in the file's blame page
  • Add a link to the file's history in the file's view page
  • Add a new endpoint and page to see a file's history
  • Add a method to run git log using the system's git
  • Enable running a command in a specific folder and return the output
4 years ago

6 new commits added

  • Add unit-tests for the view file's history feature
  • Add a link to the file's history in the file's blame page
  • Add a link to the file's history in the file's view page
  • Add a new endpoint and page to see a file's history
  • Add a method to run git log using the system's git
  • Enable running a command in a specific folder and return the output
4 years ago

rebased onto b6f9a7b56d585e051261abe39b506a4b1a7026c8

4 years ago

Nice! Note that this PR would resolve #1485.

Maybe this is a tiny bit out of scope, but could we get a snippet of the commit message next to the commit hash (similar to GitHub and GitLab) in the blame view?

Also, a screenshot of file history view would be cool to see here. I assume that's a per-file variant of the commit view.

Also, a screenshot of file history view would be cool to see here. I assume that's a per-file variant of the commit view.

That's the screenshot I've put above

Oh! I thought we were looking at the blame view... I guess I expected it to look like the commit view?

rebased onto c074debcad00358d8db6a9b4603b2e1a98bca5fd

4 years ago

rebased onto c899c272c4d392c9e0d2b826fd4e408e31cebf15

4 years ago

rebased onto 5972bd8

4 years ago

7 new commits added

  • Rework the UI for the file history page
  • Add unit-tests for the view file's history feature
  • Add a link to the file's history in the file's blame page
  • Add a link to the file's history in the file's view page
  • Add a new endpoint and page to see a file's history
  • Add a method to run git log using the system's git
  • Enable running a command in a specific folder and return the output
4 years ago

7 new commits added

  • Rework the UI for the file history page
  • Add unit-tests for the view file's history feature
  • Add a link to the file's history in the file's blame page
  • Add a link to the file's history in the file's view page
  • Add a new endpoint and page to see a file's history
  • Add a method to run git log using the system's git
  • Enable running a command in a specific folder and return the output
4 years ago

Did not look the code yet, but looks great !

Did not check all the js, but I wonder if we are actually using all the js on this view

Should we add username here for forks?

We should rely on one of the reopo's property instead you're right

And fix, this is not a tree view

tbh, I didn't check either :stuck_out_tongue:

7 new commits added

  • Rework the UI for the file history page
  • Add unit-tests for the view file's history feature
  • Add a link to the file's history in the file's blame page
  • Add a link to the file's history in the file's view page
  • Add a new endpoint and page to see a file's history
  • Add a method to run git log using the system's git
  • Enable running a command in a specific folder and return the output
4 years ago

Turns out most of the JS was not needed since there is no line numbering anymore.

Thanks for the review folks!

Pull-Request has been merged by pingou

4 years ago