OSdave
OSdave

Reputation: 8587

allow new branch only from master

is it possible to make it impossible to create a branch from any branch except master?

Here is how I work:

  1. production environment is on branch master
  2. preprod is on branch preprod
  3. when client opens a ticket I create a new branch from master, write the code to solve the ticket.
  4. I merge then this branch into preprod.
  5. I deploy in preprod and the client tests if it's OK.
  6. When it's OK I merge the branch back to master, delete the ticket branch and push/pull master.

This morning I've had a bad surprise, when I merged a ticket branch into master a lot of other stuff got merged: apparently I had created the ticket-branch from preprod.

So, is there a way to avoid this? I need to ensure that the branches I create are created from master only.

Upvotes: 2

Views: 314

Answers (1)

torek
torek

Reputation: 488163

As asked, the answer is "no". Branches—more precisely, branch labels—in git are flitty ephemeral things: they come and go as they please, as it were, and anyone at any time can create a new label attached to any commit anywhere in the repository. For instance, if you have this:

A - B - C - D       <-- main
      \
        E - F - G   <-- sub
          \
            H       <-- two

as your (entire) repository, where each single letter represents a commit and there are three named branches (main, sub, and two), I (or you) can do this:

$ git branch foo main~1

and now there is a new branch-label foo pointing to commit C:

            D       <-- main
          /
A - B - C           <-- foo
      \
        E - F - G   <-- sub
          \
            H       <-- two

Note that the commit graph is exactly the same, I just levered D up onto a new line to make it easier to see where the new label foo points.

It's also important to note here that branch labels, when they exist, are attached to a specific commit, not to (another) label. (There is such a thing as an indirect label but normally that's just for HEAD, and it doesn't really work if you try to use it as a "branch".) What makes a local branch label "a branch" is that git will move this label for you, when you make new commits.

What I think Zeeker is suggesting in a comment is to check, at the time you are about to make a new commit (before actually doing it, i.e., in a pre-commit hook):

  • On what branch, if any, is the new commit to be added?
  • Based on the name of that branch, does it attach to the graph at a point that follows your own internal rules?

Writing hooks is a bit tricky. If you're experienced with shell scripting (sh/bash), that helps a bunch. You can use any language you like but git commands are most easily run from a shell script. In any case, you then get into all the git "plumbing" commands, because they're meant to be used from scripts—in fact, many git commands are just shell scripts that invoke the plumbing commands. (For instance, git stash and the variations of git rebase are all just scripts.)

Here's an outline, entirely untested. We start with some boilerplate...

#! /bin/sh
#
# Are we on a branch, or do we have a detached HEAD?
# If detached HEAD, just exit 0 = ok to commit.  Otherwise
# set $branch to the short name of the branch.
branch=$(git symbolic-ref -q --abbrev-ref HEAD) || exit 0

Now we check which branch the new commit goes on, i.e., which label is to be moved by the new commit, if we allow it (exit 0). (Exit 1, with a message about why to disallow the commit, to prevent it.) You probably want to modify this section, e.g., to check for ticket-* as a name for instance. I just need to verify that the name is neither master nor preprod, for the remaining parts. I also need to compare the SHA-1s of master and preprod, since if they are identical, the rule "must be descendent of master but not of preprod" is literally impossible to satisfy (all descendents of master are also descendents of preprod by definition).

case "$branch" in
master) exit 0;;   # branch master, just allow (or deny=exit 1)
preprod) exit 0;;  # branch preprod, just allow
esac               # all others: fall through to next checks

# Must be a ticket branch.
# If there are no branches named preprod and master, skip all this.
# Otherwise, get their SHA-1 values.
master=$(git rev-parse -q --verify preprod) || exit 0
preprod=$(git rev-parse -q --verify master) || exit 0

# Ensure that the new commit, once made, will not be a descendant
# of branch preprod, and will be a descendent of master.  That is,
# if we add a new commit node to the graph, ancestors(new) will
# NOT include the commit labeled preprod but WILL include the
# commit labeled master.
#
# Note that if preprod and master point to the *same* commit,
# this condition can never be satisfied.  In this particular
# case (preprod == master) just allow the commit.  (We're not
# committing *on* master or preprod -- we checked that above --
# so this commit will not move either of *those* two labels.)
[ $master = $preprod ] && exit 0

Last, we enforce the rule:

# A new commit's immediate parent will be whatever the SHA-1 of HEAD
# is, so we simply check whether HEAD satisfies our conditions.
if git merge-base --is-ancestor $preprod HEAD; then
    echo "ERROR: new commit will be a descendent of" 1>&2
    echo "    branch preprod (commit $preprod)" 1>&2
    exit 0
fi
if ! git merge-base --is-ancestor $master HEAD; then
    echo "ERROR: new commit will not be a descendent of" 1>&2
    echo "    branch master (commit $master)" 1>&2
    exit 0
fi

There are some holes in the above checking. The most obvious is when both master and preprod point to the same commit:

... - o - o - o   <-- master, preprod, branch3
        \
          o - o   <-- branch4

If you're on branch3 (HEAD is branch3) and add a new commit, you get:

                  o   <-- HEAD=branch3
                /
... - o - o - o   <-- master, preprod
        \
          o - o   <-- branch4

This is how you have to think about handling this: all these operations, in git, modify the commit graph, and drag the current branch label (the one HEAD has in it) along with them. (In the "detached HEAD" case, HEAD points directly to a commit, rather than to a branch label that in turn points to the commit. You can at that time, or really any time, add a new label, and make HEAD point to it, with git checkout -b.)

Less obvious, but much bigger, is the hole knittl mentioned in a comment: git rebase will copy a series of commits to a new series and then move the label; and the git reset command moves labels as instructed (while also updating index and working tree, depending on arguments).

Upvotes: 3

Related Questions