Using giterator

Installation

giterator is available on the Python Package Index and can be installed with any tools for managing Python environments.

Normal use

Git wraps the command-line git tool, giving common repository operations a typed Python method instead of a shell command assembled by hand. Point it at an existing work tree, or create one with Git.init():

from giterator import Git, User

repo = Git('path/to/repo')
repo.init(User('Alice', 'alice@example.com'))

Any git subcommand that doesn’t have a dedicated method can still be run directly; calling a Git instance (or its git() alias) runs git in the work tree and returns its output as a string:

repo.git('remote', 'add', 'origin', 'git@example.com:some/repo.git')

If the command fails, a GitError is raised carrying git’s own output:

>>> repo.git('merge', 'no-such-branch')
Traceback (most recent call last):
...
giterator.git.GitError: 'git merge no-such-branch' gave return code 1:

merge: no-such-branch - not something we can merge
...

Git.commit() stages everything in the work tree, including new and deleted files, and commits it, optionally backdating the author and committer:

from datetime import datetime, timezone

(repo.path / 'README.rst').write_text('hello')
repo.commit('add readme', author_date=datetime(2020, 1, 1, tzinfo=timezone.utc))

Git.clone() clones a repo. When the source is a Git instance with a user configured, that user is carried over to the clone unless a different one is given:

clone = Git.clone(repo, 'path/to/clone')

Branches and tags are created and listed with Git.branch(), Git.branches(), Git.tag() and Git.tags(), and their commit hashes looked up with Git.branch_hashes() and Git.tag_hashes():

>>> repo.branch('feature')
>>> repo.tag('v1.0')
>>> repo.branches()
['feature', 'main']
>>> repo.tags()
['v1.0']

Passing bare=True to Git.init() creates a bare repository instead. A worktree of a bare repo shares its config, so configuring a user there means commits made in those worktrees don’t depend on the git configuration of the machine running the tests:

bare = Git('path/to/bare.git')
bare.init(User('Alice', 'alice@example.com'), branch='main', bare=True)
bare.git('worktree', 'add', '--orphan', '-b', 'main', '../worktree')
>>> worktree = Git('path/to/worktree')
>>> worktree.commit('add greeting')
'...'
>>> worktree.log()[0].author
User(name='Alice', email='alice@example.com')

Examining history

Git.log() returns the commits in a repository as Commit instances, giving structured access to the hash, author, committer, dates and full message of each commit. Here, after committing some docs to go with the readme:

(repo.path / 'docs').mkdir()
(repo.path / 'docs' / 'index.rst').write_text('welcome')
repo.commit('add docs', author_date=datetime(2020, 2, 1, tzinfo=timezone.utc))
>>> for commit in repo.log():
...     print(commit.author_date, commit.author.name, commit.message)
2020-02-01 00:00:00+00:00 Alice add docs
2020-01-01 00:00:00+00:00 Alice add readme

Any options, revision ranges or paths accepted by git log can be passed as strings, here reversing the order and restricting to commits that touch docs/:

>>> for commit in repo.log('--reverse', 'docs/'):
...     print(commit.author_date, commit.message)
2020-02-01 00:00:00+00:00 add docs

Iterating over history

The read() function replays the history of a repository as a series of snapshots taken on a schedule. Each snapshot is a Giteration giving the path to a checkout of the repository as it was at that point in time, along with the revision checked out and the time of the snapshot. The examples below read a repository with commits at 10:00 on 1 January, 11:00 on 2 January and 12:00 on 5 January 2001, all UTC:

>>> from giterator import daily, read
>>> for giteration in read('path/to/project', daily.at(16, 0)):
...     print(giteration.at, giteration.rev)
2001-01-01 16:00:00+00:00 5ee580a
2001-01-02 16:00:00+00:00 e3a9fbb
2001-01-05 16:00:00+00:00 4f0b0f3

Snapshots are made by cloning the repository into a temporary location, so the repository itself is never modified. Each checkout is only valid until the next snapshot is requested, and is removed when iteration finishes.

The schedule can be daily, anchored to a time of day with Every.at() as above, or any timedelta giving the gap between snapshots. Without an anchor, the schedule ticks from the date of the repository’s first commit:

>>> from datetime import timedelta
>>> for giteration in read('path/to/project', timedelta(days=2)):
...     print(giteration.at, giteration.rev)
2001-01-01 10:00:00+00:00 5ee580a
2001-01-03 10:00:00+00:00 e3a9fbb
2001-01-07 10:00:00+00:00 4f0b0f3

start begins the schedule somewhere else, with the first snapshot giving the repository as it stood at that point:

>>> start = datetime(2001, 1, 4, tzinfo=timezone.utc)
>>> for giteration in read('path/to/project', daily.at(16, 0), start=start):
...     print(giteration.at, giteration.rev)
2001-01-04 16:00:00+00:00 e3a9fbb
2001-01-05 16:00:00+00:00 4f0b0f3

As the examples above show, points on the schedule where the repository had not changed since the previous snapshot are skipped, and iteration stops once the most recent commit has been seen. Pass skip_unchanged=False to get exactly one snapshot per point instead, useful when whatever consumes them expects evenly spaced samples:

>>> snapshots = read('path/to/project', daily.at(16, 0), skip_unchanged=False)
>>> for giteration in snapshots:
...     print(giteration.at, giteration.rev)
2001-01-01 16:00:00+00:00 5ee580a
2001-01-02 16:00:00+00:00 e3a9fbb
2001-01-03 16:00:00+00:00 e3a9fbb
2001-01-04 16:00:00+00:00 e3a9fbb
2001-01-05 16:00:00+00:00 4f0b0f3

When no schedule is given, a snapshot is yielded for every commit, with the time of each snapshot being the date of its commit:

>>> for giteration in read('path/to/project'):
...     print(giteration.at, giteration.rev)
2001-01-01 10:00:00+00:00 5ee580a
2001-01-02 11:00:00+00:00 e3a9fbb
2001-01-05 12:00:00+00:00 4f0b0f3

The write() function does the reverse, turning a series of snapshots into commits in a repository, which is useful when you have dated copies of a project, such as backups, that you would like to turn into version history:

from datetime import datetime
from giterator import Giteration, write

write('path/to/new/repo', [
    Giteration('backups/2001-01-01', datetime(2001, 1, 1)),
    Giteration('backups/2001-02-01', datetime(2001, 2, 1)),
])

If the target repository does not already exist, it is created. The content of each Giteration replaces the content of the repository’s work tree and is committed using its at date for both the author and committer dates, with the at date as the message when none is given:

>>> [commit.message for commit in Git('path/to/new/repo').log()]
['2001-02-01T00:00:00', '2001-01-01T00:00:00']

Since read() yields Giteration instances and write() accepts them, the two can be combined to resample a repository’s history, here as it stood at 4pm each day. Commit messages are preserved, as read() fills in the message of each snapshot’s source commit:

>>> from giterator import write
>>> resampled = write('path/to/resampled', read('path/to/project', daily.at(16, 0)))
>>> for commit in resampled.log('--reverse'):
...     print(commit.author_date, commit.message)
2001-01-01 16:00:00+00:00 a commit
2001-01-02 16:00:00+00:00 a commit
2001-01-05 16:00:00+00:00 a commit

The giterator command line tool builds on the same read/write model to move between dated files and commits. pack looks for files matching a strftime() pattern, parses the date out of each file’s name, and commits them oldest first under the name on the right of the mapping:

giterator pack --repo path/to/repo 'downloads/foo-%Y-%m-%d.csv:foo.csv'

unpack does the reverse, copying files matching the glob pattern on the left of the mapping to the path produced by formatting each commit’s date with the pattern on the right:

giterator unpack --repo path/to/repo '*.csv:downloads/foo-%Y-%m-%d.csv'

If any of the paths involved contain a colon, --sep changes the separator used in the mapping.

Testing

Repo is a Git subclass built for use in automated tests. It configures a user and initial branch name that don’t depend on the git configuration of the machine running the tests, so the same test behaves the same way in every environment, including CI.

The usual pattern is a pytest fixture that makes a fresh repo in a temporary directory for each test:

from pathlib import Path

import pytest
from giterator.testing import Repo


@pytest.fixture()
def repo(tmp_path: Path) -> Repo:
    return Repo.make(tmp_path / 'repo')


def test_something(repo: Repo) -> None:
    repo.commit_content('data')
    commit = repo.log()[0]
    assert commit.message == 'a commit'

As test_something shows, commit_content() makes a commit in a single call: it writes a file, defaulting to sample.txt with content derived from its name, and commits with an automatically increasing timestamp, so a test can create a string of commits without inventing a file name, content, or dates by hand. When a test does care about those, or about the commit message, they can all be given, and the commit can be placed on a new branch or tagged:

from datetime import datetime

repo.commit_content('a')
repo.commit_content('b', datetime(2021, 6, 1))
repo.commit_content('c', content='some specific content')
repo.commit_content('d', message='a specific message')
repo.commit_content('e', tag='v1.0')
repo.commit_content('f', branch='feature')

That leaves the work tree with one file per call:

>>> sorted(path.name for path in repo.path.iterdir())
['.git', 'a', 'b', 'c', 'd', 'e', 'f']

make() is the usual way to create a Repo. A User and branch name can be given if the defaults, Giterator <giterator@example.com> and main, don’t suit a particular test:

from giterator import User

repo = Repo.make(
    tmp_path / 'repo', user=User('Alice', 'alice@example.com'), branch='trunk'
)

clone() works like Git.clone(), but ensures the clone always has a user configured, even when the source has none, falling back to the same default as make():

clone = Repo.clone(repo, tmp_path / 'clone')

When a test needs full control over the files in a commit, write them and use commit(). It works like Git.commit(), except that when commit_date is omitted it defaults to author_date, so one date is enough to pin both of a commit’s timestamps:

(repo.path / 'content.txt').write_text('content')
repo.commit('a commit', datetime(2020, 1, 1))