TL;DR summary
History is commits.
Rebase means "copy commits, then try to forget the originals and use the copies instead". This means "make up a new history".
Git repositories get distributed (widely copied). New histories creating by rebasing only affect the one repository. This is why you have to use --force during your push, to force another repository to accept the "history rewrite". But that affects only one other copy, not all other copies.
There are some tricks you can use to mitigate the pain, but the pain itself does not really go away. It's up to you to choose which kind of pain you prefer. The main trick is to use git rebase: either avoid git pull entirely, or configure it to run git rebase as its second Git command. But this only works to one level.
The alternative is not to use --force. This is far simpler, but does leave you with tangled histories. That still may be preferable.
Description
In Git, to a large extent, branch names don't matter. They do get used here and there, for several purposes, but they are quite fluid and impermanent—very much unlike commits, which are permanent and can never, ever be changed (but can be copied!). It therefore does you no good to, as you put it, "make an A branch off development and then branch B and C off A": Git does not care about the names, because they keep changing; it only cares about the commits, which don't.
You start off with this:
...--e--f--g <-- development
(since you used uppercase one-letter names for the branches, I switched to lowercase one-letter names for each commit here). You then add several more names:
...--e--f--g <-- development, A, B, C
Now as soon as someone—anyone—who has this repository structure, i.e., literally has these four branch names in their repository, does:
git checkout development
and makes some new commits, this is what happens in their repository:
...--e--f--g <-- A, B, C
\
h--i <-- development (HEAD)
The new commits go in, with each new commit recording its previous parent as usual. The branch name that represents the current branch (which this user's Git has recorded in this user's HEAD file) says that the current branch in this repository is the one named development, so the label that this Git keeps re-setting to the new commits made, is development. Hence the label development in this repository has advanced to point to the newest of the two new commits.
The other labels have not moved.
Now, in practice, every user has his or her own repository. Users must coordinate with each other, and this also happens through branch names—but now there are at least two Gits involved, so there are two or more sets of branch names. Everything must be viewed through this lens: I have my branches, you have your branches, and when I talk to you I call you my "remote" and I rename your branches so that they don't interfere with mine.
It's possible for every developer to talk directly to every other developer. If your group has the standard first three people named Alice, Bob, and Carol for instance, Alice could have two remotes named Bob and Carol, Bob could have two remotes named Alice and Carol, and Carol could have two remotes with, well, the obvious names. This obviously scales poorly: with a group of 30 developers, everyone has 29 remotes.
So, you probably have one centralized server, possibly even on a fancy hosting site like GitHub. This lets everyone have just one remote, which they all invariably call origin, since Git automatically uses that name unless you tell it to use something else.
This is where names like origin/master and origin/development come from, and is why we use the name origin when we git fetch origin and git push origin <branch>. That's simply a common remote: a place for everyone to share his or her commits.
The tricky part here is that this origin Git repository is, well, a Git repository. So it has commits, which are permanent but have awful, awkward hash-ID names like 9b00c5a... and badbeef... and so on, that no one could remember; so it also has branch names, like development.
A branch name's main function is to remember one of these commit hash IDs. This is why I drew the graphs above the way I drew them: the branch names "point to" the tip commit of their corresponding branches.
Note that one commit can be, and quite often is, on multiple branches simultaneously. If you add a new branch name pointing to commit i (the current tip of development), suddenly all commits that used to be on development such as e--f--g--h--i) are now on this new branch too. Delete this new branch name, and commits h and i, which were on exactly two branches, are now on just one branch.
A branch name has a secondary function: by letting Git find one particular commit, it protects that commit from being reaped by the Grim Collector, Git's "garbage collector" or "gc". That commit then protects all of its history—all the previous commits leading up to the branch tip. Since Git normally moves branch names by accreting new commits at the tip, the new commit (protected by the branch name) protects the old tip, which protects the even-older tip, all the way back to the very first commit ever.
Note that Git works backwards: we always start from the tip and work backwards through the history—the commits—by finding each commit's parent. Each commit "points back" to its parent, and that sequence of commits is the history. This is what git log shows us; and gitk or a graphical viewer, or using --graph with git log, shows us the connections from commits to their parents.
A parent with two immediate children forms a branch in the graph. For this branch to continue to exist, both forks need a name right now. Let's git checkout B (get on the B branch) and make a new commit to make this happen:
j <-- B (HEAD)
/
...--e--f--g <-- A, C
\
h--i <-- development
The parent here is g and the two children are h and j. The current branch name, B, has moved: it now points to j.
If we now check out branch C and make a new commit or two, the picture becomes harder to draw, because we need to leave A pointing to commit g and ASCII art doesn't really have that much room:
j <-- B
/_------ A
...--e--f--g--k--l <-- C (HEAD)
\
h--i <-- development
Again, though, we're assuming that all this happens in one repository—in a sort of vacuum where no other repositories exist. It's also getting much too hard to draw, so let's drop back to having just branches name B and developments, and let's assume that we have, somehow, gotten into this state both on the repository at origin and at one person's repository—Alice's, say—so that we now have this in both the Alice and origin repositories:
...--e--f--g--j <-- B
\
h--i <-- development
Now if Bob runs git fetch from origin, and assuming Bob has no branches of his own yet (he must have at least one, we're just not drawing it in), this is what Bob gets:
...--e--f--g--j <-- origin/B
\
h--i <-- origin/development
That is, Bob's Git remembers, for Bob, what was in origin the last time Bob ran git fetch origin (NB: let's avoid git pull for now, but note that the first thing it does is run git fetch origin). It does so by renaming the branches it finds on origin, so that they're not branches any more.
If Bob now runs git checkout development, Bob's Git finds that Bob doesn't have a development yet. It searches through Bob's repository and finds origin/development, which sure looks a lot like development, so Bob's Git creates a new development, pointing to the same commit that origin/development points to. Now Bob has this:
...--e--f--g--j <-- origin/B
\
h--i <-- development (HEAD), origin/development
If Bob runs git checkout master he'll still have the names origin/B (pointing to j), development (pointing to i), and origin/development (pointing to i too); but he will check out some other commit (maybe d that we haven't drawn in?), and attach his HEAD to master.
You might wonder why Alice doesn't have these origin/ names. The truth is, she does. She has origin/B pointing to j, and origin/development pointing to i. Note that Alice already also has her own B and development names. Bob does not (yet) have his own B, only his own development.
(The repository over on origin, though, probably doesn't have any origin/ names. It just has its own branches. This is because it does not have a user using it; that non-existent user does not run git fetch origin.)
Let's go back over to Alice now, and suppose Alice runs a git rebase to rebase development so that it comes after Alice's B:
$ git checkout development
$ git rebase B
What git rebase does is copy some commits.
The way it chooses which commits to copy is to look at the current branch, and then to look at its arguments.
The current branch is development and the argument is B. Here's what Alice has right now (which is remarkably similar to what Bob has):
...--e--f--g--j <-- B, origin/B
\
h--i <-- development (HEAD), origin/development
The commits that git rebase is to copy are those that are on the current branch (development), except for any commits that are on branch B. Well, commits ...--e--f-g--j--i are on development and ...--e--f--g--j are on B. So Git subtracts j from the e--f--g--h--i set—that's easy, there is no j, so nothing happens. Then Git subtracts g from the set, and also ...--e--f, leaving h--i.
These are the commits to be copied.
The place where they will go, after being copied, is whatever commit B points to, i.e., they are to go after commit j.
Now Git makes the copies (using what Git calls "detached HEAD" mode):
h'-i' <-- HEAD
/
...--e--f--g--j <-- B, origin/B
\
h--i <-- development, origin/development
These new copies have different hash IDs. They are different commits! They are copies (with some changes) of the original h--i, hence we call them h'-i' here. The originals are still in there, because there are still names holding them frozen into place.
The last step of git rebase is to forcibly move the original branch name, development, to point to the tip-most copied commit, and re-attach your HEAD:
h'-i' <-- development (HEAD)
/
...--e--f--g--j <-- B, origin/B
\
h--i <-- origin/development
The name that still keeps h--i around is origin/development. Alice's Git now translates development into "commit i'" (whatever its new hash ID is), so the history of her rewritten development ends with i', goes back to h', then goes back to j, g, f, etc., in Git's usual backwards fashion.
Force push
If Alice now tries to git push origin development, she gets a rejection. This is because the Git over at origin still has this:
...--e--f--g--j <-- B
\
h--i <-- development
Alice's Git sends over commits h' and i', i.e., the commits she has that origin doesn't, along with a proposal:
h'-i' <-- proposal: move development here
/
...--e--f--g--j <-- B
\
h--i <-- development
Origin's Git looks at the proposal and says: uh, no, that's not a "fast forward". DENIED!
Remember earlier we saw that Git branches grew by accreting new commits onto their ends. If Alice were pushing new commits that added on to h--i, that would be OK: that's a "fast forward" operation (adding several commits at once, rather than one at a time). But she's pushing these new commits and then proposing to yank development away from h--i entirely, and that's not OK: that's not a "fast forward".
But Alice can use --force to make it a command, rather than a proposal; and then, as long as origin's Git doesn't object too much, origin's Git obeys and changes its repository to look like this:
h'-i' <-- development
/
...--e--f--g--j <-- B
This is the beginning of the pain for everyone but Alice (and origin itself).
Note that commits h--i are just gone from the repository at origin. (If the server at origin is using Git's reflogs, the reflogs there will retain them for a while. By default, servers do not keep reflogs, though.)
Now Bob feels the pain of an upstream rebase
At this point, when Bob runs git fetch origin—remember, if you (or Bob) is using git pull, you are really running git fetch origin first—Bob gets the updated origin/development, because these origin/whatever names just slavishly follow whatever has happened on the remote repository. That's what Bob wants: Bob's branches are not named origin/whatever; those origin/whatevers are remote-tracking branches (always separate from all of Bob's branches).
So, now Bob has this (let's assume Bob has back on development so that it is his HEAD too):
h'-i' <-- origin/development
/
...--e--f--g--j <-- origin/B
\
h--i <-- development (HEAD)
It's up to Bob to (somehow) figure out that there was what we call an upstream rebase: origin/development used to point to commit i and now points to commit i' instead. Bob didn't write commits h--i himself, he only has them on his development because that's where his Git set his development back when he ran the git checkout development that created his development.
But it sure looks, to Bob's Git, like Bob wrote those commits himself, because origin/development doesn't have them.
In this particular case (Bob has no work of his own), Bob can just run git reset --hard origin/development to make his Git move his development to match origin/development. But what if Bob did make a new commit on development? Let's draw this situation:
h'-i' <-- origin/development
/
...--e--f--g--j <-- origin/B
\
h--i--k <-- development (HEAD)
What Bob has to do now is to do his own kind of git rebase. He needs to copy his commit k to come after i'.
Automatic upstream rebase handling with fork-point
Git has a feature in which it uses the reflogs (not illustrated above) to try to figure out which commits are actually Bob's. It usually just works: Bob's Git can figure out what commit k is Bob's, and commits h--i are left over from an earlier origin/development value.
Hence, Bob can run:
git rebase --fork-point origin/development
while Bob is on (Bob's) development. The --fork-point option makes Git try to figure all this out.
Note that --fork-point is the default for some rebases, and not for others. The idea is to make all this work seamlessly and automatically. In practice, the seams show all over the place.
The place where --fork-point gets used automatically is with git rebase with no arguments, or the git rebase that git pull runs if you make git pull run git rebase.
By default, git pull runs git merge instead ... and that merges the original h--i with the new h'--i', bringing back those commits Alice was trying to remove with her history rewrite! So either don't use git pull at all (my preference), or set it up to use git rebase as its second step. (Its first step is always git fetch.)
Unfortunately, all of this only works when you automatically rebase your development on your origin/development by virtue of your development having origin/development set as its upstream. If you run an explicit git rebase origin/development, that turns off the --fork-point option, and you must specify --fork-point too. It's all quite confusing ... which is probably why some prefer git pull, which hides all this from you. The problem is, it's all a bit too raw to hide: it doesn't always work, and you need to know what is happening, and why, so you can fix it.
When the automatic stuff doesn't work, you must rebase by hand
If none of the automation will do the job, each user (Bob, Carol, Dmitry, and so on) can do a manual git rebase, or git rebase -i. An interactive rebase allows the user to strip out commits that should not be copied, such as the h--i set in the example above. It's not the most fun exercise. It does leave you with a clean history; but it's up to you to decide whether this kind of clean history is worth the pain.