Git

In this chapter we will cover git. Git is a version control system. That means you can use it to keep track of your code and how it changes over time. If you keep your code "under git" you can go back to any point in time and know what changed, why and how.

I would much rather not have to explain git. I would prefer to explain any of the alternatives... except there aren't any. Once upon a time there were, but they lost, and what we have is git, which is powerful, but complicated and confusing for many.

So, I will try to give a very gentle introduction to a subset of git which makes sense for amateur not-quite-beginning programmers. Just keep in mind that there is much more git than shown here.

In this chapter we will focus on using git for controlling change in a local copy of the code. In the next we will see how it can be used to coordinate changes with a remote version, which is one of the more powerful and useful features in git.

If you already know git, please skip this. It's only going to piss you off because of the shortcuts and intentional inaccuracies.

Getting Started

First you will need to install it. Go to http://www.git-scm.com/downloads and install it like any other software.

We will be using git from the command line. There are other ways:

  • It can integrate in your IDE
  • You can use a GUI
  • You can use alternative command line interfaces

But because I don't want to force you to use a specific tool, we will use the most basic and ubiquitous interface for it: the git command.

If you already installed it, let's check if it's working:

 1$ git
 2
 3usage: git [--version] [--help] [-C <path>] [-c <name>=<value>]
 4           [--exec-path[=<path>]] [--html-path] [--man-path] [--info-path]
 5           [-p | --paginate | --no-pager] [--no-replace-objects] [--bare]
 6           [--git-dir=<path>] [--work-tree=<path>] [--namespace=<name>]
 7           <command> [<args>]
 8
 9These are common Git commands used in various situations:
10
11start a working area (see also: git help tutorial)
12   clone      Clone a repository into a new directory
13   init       Create an empty Git repository or reinitialize an existing one
14
15[...]
16
17'git help -a' and 'git help -g' list available subcommands and some
18concept guides. See 'git help <command>' or 'git help <concept>'
19to read about a specific subcommand or concept.

Let's put this out there: that help is absolutely useless. Don't try to understand that usage description. That way lies madness. The only way to make sense of it is to already be deeply familiar with git, and that would mean you don't need it.

If you get an error instead of that help text, it could be your git is not properly installed.

Concepts

Here are some of the concepts you just need to remember when using git. They can't be guessed, you just have to remember what they are.

Repositories

A git repository is just a folder. The contents of that folder are the files git "controls". The only "special" thing about a folder that makes it a git repository is that it has a hidden .git subfolder.

The .git subfolder is used by git to store bookkeeping information:

  • What files are important and needs to be controlled by git?
  • How have those files changed in the past?
  • What were those changes?

And lots more. You never need to look or modify the contents of the .git folder yourself except via the git command, but please don't delete it.

Having said that, let's create our very own repository!

1$ mkdir my-repo
2$ cd my-repo
3$ git init
4Initialized empty Git repository in my-repo/.git/

I created a folder, moved there and called git init which initialized the repo. It says it's empty because, yes, there is nothing there.

Git by default operates on the current folder. Always keep in mind where you are standing in your filesystem when using git.

You will usually have a separate git repository (or "repo") for each project you start.

Changes

Git is all about keeping track of changes to your files. Those changes are of different kinds:

  • Adding files
  • Editing files
  • Removing files

Let's go over each one.

Adding a file

An empty repository is not very useful, so let's make it not-so-empty by creating a file, and try another git "subcommand", git status:

 1$ echo 'Hello git' > hello.txt
 2$ git status
 3On branch master
 4
 5No commits yet
 6
 7Untracked files:
 8  (use "git add <file>..." to include in what will be committed)
 9
10        hello.txt
11
12nothing added to commit but untracked files present (use "git add" to track)

Let's look at that output little by little.

  • On branch master Please ignore that for a little bit. We will get to branches later.
  • No commits yet Again, we will see commits very soon.
  • Untracked files: [...] hello.txt This is the part we care about right now.

We added a file, and it appears as "untracked". Just because you put a file in the repository folder, it doesn't mean git cares about it. For that you need to "add" it.

 1$ git add hello.txt
 2$ git status
 3On branch master
 4
 5No commits yet
 6
 7Changes to be committed:
 8  (use "git rm --cached <file>..." to unstage)
 9
10        new file:   hello.txt

The new file hello.txt is now in what is called the staging step. Every time you do something with git, it is done in two steps:

  • Stage the change
  • Commit the change

We told git "hey, keep track of this file". Now we have to tell it "Make it so". Make git commit to keeping track of it.

1$ git commit hello.txt -m 'Added hello file'
2[master (root-commit) 3e3d854] Added hello file
3 1 file changed, 1 insertion(+)
4 create mode 100644 hello.txt

Again, we need to look at this carefully.

git commit hello.txt

That means "git, commit the file hello.txt". Remember that we had staged a change? Well, this commits the change. That means it's now stored in git's memory. It now knows about that file we created and added and will never forget about it.

If you have changed more than one file, you can either list them all in the command, or use the -a option, which means "commit all files that are in the staging state".

-m 'Added hello file'

When you commit something in git, you are required to give a "commit message". It should be a brief description of what you are doing / why you are doing it.

You can either use the -m option like this, or just put nothing there, and you will get a text editor where you can enter a longer description. If you do the long description, try to do a short one in the first line, then leave a blank and add the longer explanation there, like this:

 1Added hello file
 2
 3I am trying to explain git, so I am starting with
 4adding a file
 5
 6# Please enter the commit message for your changes. Lines starting
 7# with '#' will be ignored, and an empty message aborts the commit.
 8#
 9# On branch master
10#
11# Initial commit
12#
13# Changes to be committed:
14#       new file:   hello.txt

All the lines starting with # at the bottom are for your information, they will not be part of the commit message.

Commit messages are important. If you ever use 'fixes' or 'tweaks' or 'minor changes' then people who are collaborating with you will have absolutely no idea what you are doing. Commit messages are the diary of your work. They should be expressive and well written.

[master (root-commit) 3e3d854] Added hello file

Master is the branch (we will get to that), 3e3d854 is a way to identify the commit we just did. Each commit has one. It's actually much longer. In this case it is actually 3e3d85457297b25b759e7033fab9a9075c823022 but that is a mouthful, so there is also the "short" version which is just the beginning of it.

The proper name for that is actually a revision. Each commit creates a revision. In practice you can use either name most of the time.

You will use those commit identifiers in the future. For example, you may say "oh, yes, I remember hello.txt was added in commit 3e3d854!".

Added hello file is either the commit message (if you used -m) or the first line of it if you used the editor.

1 file changed, 1 insertion(+)\ create mode 100644 hello.txt

Git will give you some idea of what changed in this commit. Look at it to make sure it describes what you were trying to do.

1$ git status
2On branch master
3nothing to commit, working tree clean

This status is what it says: there are no untracked files, no staged changes, all good.

Editing a file

Lets edit hello.txt and change its content. I added a second line that says "Bye git".

1$ cat hello.txt
2Hello git
3Bye git

If git is any good about tracking changes to my files, it should notice.

1$ git status
2On branch master
3Changes not staged for commit:
4  (use "git add <file>..." to update what will be committed)
5  (use "git checkout -- <file>..." to discard changes in working directory)
6
7        modified:   hello.txt
8
9no changes added to commit (use "git add" and/or "git commit -a")

And it did notice. I will ask you to ignore git's suggestions about using git add or git commit -a. Yes, you can do that, but there is no need, really.

Not only does git know we changed hello.txt, it can tell you how:

$ git diff
1diff --git a/hello.txt b/hello.txt
2index 0dec223..d514db9 100644
3--- a/hello.txt
4+++ b/hello.txt
5@@ -1 +1,2 @@
6 Hello git
7+Bye git
8

I am not going to explain diff syntax, but here are the highlights:

  • The a/hello.txt b/hello.txt tells you the change is in hello.txt
  • The change is that we added a line, that is why it says +Bye git. When removing lines that would be a -

The change we made is not committed yet. We can commit it just the same as before:

1$ git commit hello.txt -m 'Say bye'
2[master 6ea17af] Say bye
3 1 file changed, 1 insertion(+)

Deleting a file

The final change type is deleting a file.

 1$ rm hello.txt
 2$ git status
 3On branch master
 4Changes not staged for commit:
 5  (use "git add/rm <file>..." to update what will be committed)
 6  (use "git checkout -- <file>..." to discard changes in working directory)
 7
 8        deleted:    hello.txt
 9
10no changes added to commit (use "git add" and/or "git commit -a")

Again, it's deleted but it's not really gone because we have not commited the deletion.

1$ git commit hello.txt -m 'Not needed anymore'
2[master 606186a] Not needed anymore
3 1 file changed, 2 deletions(-)
4 delete mode 100644 hello.txt

And just like that, our file is gone. But not forever.

History

The whole point of git, as mentioned, is to keep track of our files and how they change. We have created, modified, and deleted a file. How can we see what happened?

 1$ git log
 2commit 606186a457d92fd9921fbeade3b20e6b014c7120
 3Author: Roberto Alsina <ralsina@netmanagers.com.ar>
 4Date:   Sat Apr 14 13:11:22 2018 -0300
 5
 6    Not needed anymore
 7
 8commit 6ea17af0a2b79388a4df1f9b9540cb5caacaf5ab
 9Author: Roberto Alsina <ralsina@netmanagers.com.ar>
10Date:   Sat Apr 14 11:06:08 2018 -0300
11
12    Say bye
13
14commit 3e3d85457297b25b759e7033fab9a9075c823022
15Author: Roberto Alsina <ralsina@netmanagers.com.ar>
16Date:   Sat Apr 14 10:31:38 2018 -0300
17
18    Added hello file
19
20    I am trying to explain git, so I am starting with
21    adding a file

Git has kept a record of all the changes. And not just a record that they happened. It has the history. We can actually move back and forth in time through it. Right now `hello.txt doesn't exist:

1$ ls hello.txt
2ls: cannot access 'hello.txt': No such file or directory

But in the log I see I created it in commit 3e3d etc. So I can use git checkout to bring the repo to the state in that commit:

1$ git checkout 3e3d85457297b25b759e7033fab9a9075c823022
2Note: checking out '3e3d85457297b25b759e7033fab9a9075c823022'.

Now git will give us one of its usual technically true but not really what you need to know right now texts.

1You are in 'detached HEAD' state. You can look around, make experimental
2changes and commit them, and you can discard any commits you make in this
3state without impacting any branches by performing another checkout.
4
5If you want to create a new branch to retain commits you create, you may
6do so (now or later) by using -b with the checkout command again. Example:
7
8  git checkout -b <new-branch-name>

And look, we are now in the "Added hello file" commit!

HEAD is now at 3e3d854 Added hello file

And the file exists again!

1$ cat hello.txt
2Hello git

We could edit it and change it and do things with it. But we shouldn't.

This is what our git history looked like after we did our last commit. The yellow commit is what we have "checked out", which is usually the latest. In git slang that is called HEAD (as in the head in a cassette tape, not head as in a snake, that is why it's not always on the last commit).

After we checked out an earlier revision, this is how it looked like:

So, the file exists, because we have checked out a revision when the file existed.

But if we were to change things now, things would get weird.

1$ echo 'Hello, fellow git' > hello.txt
2$ git commit hello.txt -m 'Git is a fellow'
3[detached HEAD 6e1e76f] Git is a fellow
4 1 file changed, 1 insertion(+), 1 deletion(-)

See that detached HEAD there? Detaching heads is never good.

1$ git status
2HEAD detached from 3e3d854
3nothing to commit, working tree clean

Remember how it used to say master? And now it says HEAD detached. Let's look at te log, too.

 1$ git log
 2commit 6e1e76f98fe1c1bb1d46a395480b71503254b3c0 (HEAD)
 3Author: Roberto Alsina <ralsina@netmanagers.com.ar>
 4Date:   Sat Apr 14 13:38:27 2018 -0300
 5
 6    Git is a fellow
 7
 8commit 3e3d85457297b25b759e7033fab9a9075c823022
 9Author: Roberto Alsina <ralsina@netmanagers.com.ar>
10Date:   Sat Apr 14 10:31:38 2018 -0300
11
12    Added hello file
13
14    I am trying to explain git, so I am starting with
15    adding a file

What happened to the other commits? To "Say bye" and "Not needed anymore"? They don't exist anymore. We went back in time and married their mom, so they don't exist.

Just kidding, they exist, but this is how our history looks now:

Any further changes we do would go in this "detached HEAD" alternative universe and now you have basically two copies of everything and it all got very complicated. Please don't get yourself in detached HEAD situations unless you know what you are doing.

What you want, if you want to keep things reasonable, are branches, which we will see shortly.

However, there is a perfectly reasonable thing to do that involves going back in time.

You committed changes by mistake.

What happens if you just want to undo a commit? There are two ways to do it.

You committed something before it was ready. So you want to keep the changes but remove the commit?

In this case, use git reset --soft commit-you-want-to-keep

 1$ git reset --soft 6ea17af0a2b79388a4df1f9b9540cb5caacaf5ab
 2$ git log
 3commit 6ea17af0a2b79388a4df1f9b9540cb5caacaf5ab (HEAD -> master)
 4Author: Roberto Alsina <ralsina@netmanagers.com.ar>
 5Date:   Sat Apr 14 11:06:08 2018 -0300
 6
 7    Say bye
 8
 9commit 3e3d85457297b25b759e7033fab9a9075c823022
10Author: Roberto Alsina <ralsina@netmanagers.com.ar>
11Date:   Sat Apr 14 10:31:38 2018 -0300
12
13    Added hello file
14
15    I am trying to explain git, so I am starting with
16    adding a file

As you can see, the 3rd commit is gone. It's really gone! Git knows nothing about it anymore. However, because it's a "soft" reset, the change itself still happened:

1$ ls hello.txt
2ls: cannot access 'hello.txt': No such file or directory

You committed something and it's crap. So you want to remove all memory of the changes. Do not do this unless you realy have to. For example, if you commit your password in the repo? Yes, you want to remove all trace. But you just made a mistake? Fix it and commit the fix. Everyone makes mistakes!

So, if you really want to lose data, with great caution use git reset --hard commit-you-want-to-keep

1$ git reset --hard 6ea17af0a2b79388a4df1f9b9540cb5caacaf5ab
2HEAD is now at 6ea17af Say bye

Not only is that last commit gone now (log looks like after a git reset --soft) but the change itself is gone:

1$ cat hello.txt
2Hello git
3Bye git

Using git reset deletes the newer part of history. Now our history looks like this:

The only difference between --soft and --hard is that changes are irrecoverably lost using --hard. It's one of the easiest ways to lose a lot of work. Please be careful.

Branches

Suppose you want to work on a large change to a project. It will take several days to finish, and you want to keep on using the code for other things in the meantime.

Without using git, what you would do is probably make a copy, work on the copy, use the original, and later on bring those changes back into the original.

Suppose in the meantime, while you are working on the feature, you also need to fix a small bug. Now, bringing the changes back is starting to look complicated, right?

Git is meant to help handle those cases through the use of branches.

Here is an example. Let's start by creating an empty repo, like before.

1$ mkdir my-repo
2$ cd my-repo/
3$ git init .
4
5Initialized empty Git repository in /tmp/my-repo/.git/

Let's put a file there and commit a few revisions.

 1$ touch shoplist.txt
 2$ git add shoplist.txt
 3$ git commit shoplist.txt -m 'Empty shopping list'
 4[master (root-commit) 07dfb41] Empty shopping list
 5 1 file changed, 0 insertions(+), 0 deletions(-)
 6 create mode 100644 shoplist.txt
 7$ echo '* Coffee' > shoplist.txt
 8$ git commit shoplist.txt -m 'Buy Coffee'
 9[master 12a3d5e] Buy Coffee
10 1 file changed, 1 insertion(+)
11$ echo '* Donut' >> shoplist.txt
12$ git commit shoplist.txt -m 'Buy donut'
13[master 38f48fd] Buy donut
14 1 file changed, 1 insertion(+)
15$ echo '* Milk' >> shoplist.txt
16$ git commit shoplist.txt -m 'Buy milk'
17[master b3c2b3e] Buy milk
18 1 file changed, 1 insertion(+)
19$ cat shoplist.txt
20* Coffee
21* Donut
22* Milk

Our history looks like this:

Now, you want to add the list of supplies for your workshop, but you want the list to be as-is until you are done.

We branch.

 1$ git checkout -b workshop
 2Switched to a new branch 'workshop'
 3$ echo "* Bandsaw" >> shoplist.txt
 4$ git commit shoplist.txt -m 'Buy a bandsaw for the workshop'
 5[workshop fda2474] Buy a bandsaw for the workshop
 6 1 file changed, 1 insertion(+)
 7$ echo "* Anvil" >> shoplist.txt
 8$ git commit shoplist.txt -m 'Buy anvil for the workshop'
 9[workshop 1eaefa3] Buy anvil for the workshop
10 1 file changed, 1 insertion(+)

If we look at the history at this point, this is what it looks like:

Except that is not really true. This is how it really looks.

Remember "master"? Master is a branch. It's the first branch in every git repo, it always exists, and it's the default branch. If you never do git checkout -b then it will always be the only branch.

When we did git checkout -b workshop we created a new branch, called workshop. From that point forward, every commit belongs to that branch. You can switch between branches using git checkout branchname, just like we used to travel in history using git checkout commit_identifier.

To know on what branch you currently stand, you can use git branch

1$ git branch
2  master
3* workshop
4$ git checkout master
5Switched to branch 'master'
6$ git branch
7* master
8  workshop

Each branch is sort of a parallel universe:

 1$ git branch
 2* master
 3  workshop
 4$ cat shoplist.txt
 5* Coffee
 6* Donut
 7* Milk
 8$ git checkout workshop
 9Switched to branch 'workshop'
10$ cat shoplist.txt
11* Coffee
12* Donut
13* Milk
14* Bandsaw
15* Anvil

When you fork a branch, everything that is in the past from that branching point is in both branches, and everything that happens later is only on the branch where you did it.

To show this, we can go to the master branch and do something:

1$ git checkout master
2Switched to branch 'master'
3$ echo "* Sugar" >> shoplist.txt
4$ git commit shoplist.txt -m 'sugar too'
5[master 47592f1] sugar too
6 1 file changed, 1 insertion(+)

That change happened on master, but did not happen on the workshop branch:

1$ git checkout workshop
2Switched to branch 'workshop'
3$ cat shoplist.txt
4* Coffee
5* Donut
6* Milk
7* Bandsaw
8* Anvil

And now our history is a bit more complex:

Notice how in the graph we have two HEADs? Each branch has one.

So, we can have as many branches as we want... but in the end, we probably want to bring the changes we did in the workshop branch back into master.

That is done via merge and rebase

Tags

Tags mark a moment in a branch. You can:

  • Create a tag: git tag tagname
  • List the tags: git tag -l
  • Remove a tag: git tag -d tagname
  • Put the repo in the exact state marked by a tag: git checkout tagname

Use it as a bookmark for important things. For example, if you are making a release of a program you are writing, tag it.

I will not go into details about tags. There is lots of things about them online.

Merge

Merging works like this. If you are standing in branch A and want the changes from branch B, you say git merge B.

So, for example, if I wanted to bring the changes in workshop into master it would go like this:

1$ git checkout master
2Switched to branch 'master'
3$ git merge workshop
4Auto-merging shoplist.txt
5CONFLICT (content): Merge conflict in shoplist.txt
6Automatic merge failed; fix conflicts and then commit the result.

Oh, crap. Often, nothing bad will happen. Not in this case. When two branches edit the same parts of a file, it will end up as a conflict. They look like this:

1* Coffee
2* Donut
3* Milk
4<<<<<<< HEAD
5* Sugar
6=======
7* Bandsaw
8* Anvil
9>>>>>>> workshop

You have:

<<<<<<< HEAD

That <<<<<< marks the beginning of a conflict. It's followed by changes you have in your current branch. In this case, the * Sugar line.

Then you have ====== which marks the end of the changes in the current branch, and the beginning of the changes in the branch you are trying to merge.

In the workshop branch, we had two new lines: Bandsaw and Anvil. Then there is a >>>>>> marking the end of the conflict.

In some cases, depending on how complicated the merge is, how much have things changed between branches, and how close to each other the changes are, you will have many conflicts in one or more files.

What we have to do is resolve the conflict. Take that whole chunk delimited between <<<<<< and >>>>>> and fix it.

In this case, the fix is simple: we want all the items in the list!

1* Coffee
2* Donut
3* Milk
4* Sugar
5* Bandsaw
6* Anvil

Once you have fixed all the conflicts in a file, you need to tell git that you did:

1$ git add shoplist.txt
2$ git status
3On branch master
4All conflicts fixed but you are still merging.
5  (use "git commit" to conclude merge)
6
7Changes to be committed:
8
9        modified:   shoplist.txt

And now we finish the merge by committing (it always has to be done using git commit -a):

1$ git commit -a -m 'merged the workshop branch'
2[master aea0f88] merged the workshop branch

And now our history looks like this:

Just because we merged it, it doesn't mean anything happened to our workshop branch. It's still there, we can still add commits to it, we could even branch off it by doing git checkout -b while it's our current branch, and so on.

In fact, if you have long lived branches you often have to merge things back from master into those branches so they don't diverge too much.

Often that is done via rebasing.

Rebase

Let's go back to the status quo pre-merge. We are in this state:

The difference between git merge and git rebase is this:

  • Merge takes the difference between this branch and the "other" branch and tries to patch the current branch to have all those changes. It applies the changes on top of our last commit.

  • Rebase takes the "other branch" as it is now, and tries to reapply the changes you made in the "current branch" on top of that. It applies the changes below our branch.

Let's try to rebase workshop off current master:

1$ git checkout workshop
2Switched to branch 'workshop'
3$ git rebase master
4First, rewinding head to replay your work on top of it...

It goes to our branching point from master (before the bandsaw), applies all the changes that happened on master (buy milk) and then tries to apply each commit from workshop.

Of course adding "bandsaw" causes a conflict:

 1Applying: Buy a bandsaw for the workshop
 2Using index info to reconstruct a base tree...
 3M       shoplist.txt
 4Falling back to patching base and 3-way merge...
 5Auto-merging shoplist.txt
 6CONFLICT (content): Merge conflict in shoplist.txt
 7error: Failed to merge in the changes.
 8Patch failed at 0001 Buy a bandsaw for the workshop
 9The copy of the patch that failed is found in: .git/rebase-apply/patch
10
11When you have resolved this problem, run "git rebase --continue".
12If you prefer to skip this patch, run "git rebase --skip" instead.
13To check out the original branch and stop rebasing, run "git rebase --abort".

How does the conflict look?

1* Coffee
2* Donut
3* Milk
4<<<<<<< 47592f16f4c712b7caa444f6d3ed76e58f903eb9
5* Sugar
6=======
7* Bandsaw
8>>>>>>> Buy a bandsaw for the workshop

We know how to fix it:

* Coffee
* Donut
* Milk
* Sugar
* Bandsaw

We mark the conflict as resolved using git add and continue the rebase:

1$ git add shoplist.txt
2$ git rebase --continue
3Applying: Buy a bandsaw for the workshop
4Applying: Buy anvil for the workshop
5Using index info to reconstruct a base tree...
6M       shoplist.txt
7Falling back to patching base and 3-way merge...
8Auto-merging shoplist.txt

If we look at the log, now our workshop branch has the changes that were on master before its own!

1$ git shortlog
2Roberto Alsina (7):
3      Empty shopping list
4      Buy Coffee
5      Buy donut
6      Buy milk
7      sugar too
8      Buy a bandsaw for the workshop
9      Buy anvil for the workshop

Because that is what rebase does. It takes the changes in the other branch, and then applies the changes in your current branch on top of those.

And now our history looks like this:

We moved the branching point of the workshop branch forward to the HEAD of master.

The advantage of using git rebase to bring changes from master to our branch is that merges from our branch to master will always be clean, because our changes apply after those in master:

 1$ git checkout master
 2Switched to branch 'master'
 3$ git merge workshop
 4Updating 47592f1..21501f8
 5Fast-forward
 6 shoplist.txt | 2 ++
 7 1 file changed, 2 insertions(+)
 8$ git status
 9On branch master
10nothing to commit, working directory clean

And our history is straightforward because now both branches are exactly the same! There are no changes that are in master and not in workshop, or viceversa.

1$ git shortlog
2Roberto Alsina (7):
3      Empty shopping list
4      Buy Coffee
5      Buy donut
6      Buy milk
7      sugar too
8      Buy a bandsaw for the workshop
9      Buy anvil for the workshop

Yes, the workshop branch still exists. But it looks exactly like master, so no point in even drawing it. In fact, after a temporary branch is merged, you may just want to delete it using git branch -d workshop

Why did I say temporary branch? Because sometimes you want to have permanent branches. For example, some people keep a develop branch alive at all times where they work, and when they want to freeze it to release a version, they would fork a branch out for the release.

There is much, much more to know about branches and merging and other things, but this should be enough... to work on your own. But git is at its best when used to collaborate with others, and that is going to happen in the next chapter.

results matching ""

    No results matching ""