Thuta Learning
ProjectsDevOps & Toolsintermediate

Project: Two Developers, One File — Collaboration and Conflict

What you'll walk away with

  • Explain the core ideas behind Project: Two Developers, One File — Collaboration and Conflict
  • Read the diagram and trace how state or data flows through the Git/GitHub workflow
  • Decide how this applies to your own project or team

Build the mental model

This project puts branching, remotes, pushing, pulling, and conflict resolution into one realistic scene: two developers editing the same file at the same time, and nothing here is staged or simplified.

  • Both developers clone the same shared repository, starting from identical history
  • Developer A commits and pushes first, and Git accepts it as a fast-forward
  • Developer B's push is rejected because the remote moved without them
  • git pull triggers a real merge conflict on the shared line
  • Resolving it is a manual decision, not something Git can automate

This rejection is not a bug. It is Git protecting the team from silently overwriting a teammate's already-pushed work, forcing a pull before any further push can succeed.

A rejected push is not a bug

It means the remote has commits you don't have locally yet. The fix is always the same: pull (or fetch + merge/rebase), resolve anything that conflicts, then push again.

SituationWhat Git does
Clean pushLocal history is ahead of remote with no divergence -- fast-forward, no conflict
Rejected pushRemote has commits your local branch doesn't -- push refused until you pull
Merge conflictBoth sides changed the same lines -- Git stops and asks a human to decide
text
TWO DEVELOPERS, ONE FILE: PUSH, REJECT, CONFLICT, RESOLVE
---------------------------------------------------------
            TEAM REPO (bare repo, stands in for GitHub)
                 ^                            ^
                 | clone                      | clone
                 |                            |
           DEV A CLONE                  DEV B CLONE
     edit config.py, line X       edit SAME config.py, line X
     commit (author: Aye)         commit (author: Banyar)
                 |                            |
     push main -> OK (fast-forward)           |
                 |                            |
     TEAM REPO now ahead ------------> push main -> REJECTED
                                       "fetch first" (remote moved)
                                               |
                                        git pull (fetch + merge)
                                               v
                                   CONFLICT in config.py:
                                   <<<<<<< HEAD (Banyar's line)
                                   =======
                                   >>>>>>> commit-hash (Aye's line)
                                               |
                                   edit file by hand, remove markers
                                   git add config.py
                                   git commit  (merge commit)
                                               |
                                        push main -> OK
                                               v
             TEAM REPO now holds both developers' changes

Connect it to a real scenario

Create the shared team repo

git init --bare a bare repository to stand in for GitHub, then clone it twice into two separate folders -- one per developer.

Developer A edits and pushes

Change one specific line in the shared file, commit with A's pinned identity, and push. Since A's history matches the remote, this push succeeds as a clean fast-forward.

Developer B edits the same line, unaware

Working from a clone that hasn't seen A's push, edit that same line differently, and commit with B's own pinned identity.

Developer B's push is rejected

Attempt to push, and Git refuses, telling you the remote has work you don't have locally -- fetch first.

Pull and hit the real conflict

git pull fetches A's commit and tries an automatic merge. Because both edits touch the same line, it fails, leaving the file mid-merge with real conflict markers.

Resolve by hand

Open the file, read both versions between <<<<<<<, =======, and >>>>>>>, decide what the line should say, and delete every marker.

Stage, commit, and push the merge

git add the resolved file, commit to complete the merge, then push -- this time it succeeds, carrying both developers' work forward together.

Try the working example

bash
git init --bare /tmp/team-repo.git
git clone /tmp/team-repo.git /tmp/seed
cd /tmp/seed
export GIT_AUTHOR_NAME="Team Lead"
export GIT_AUTHOR_EMAIL="lead@example.com"
export GIT_COMMITTER_NAME="Team Lead"
export GIT_COMMITTER_EMAIL="lead@example.com"
export GIT_AUTHOR_DATE="2026-02-01T09:00:00"
export GIT_COMMITTER_DATE="2026-02-01T09:00:00"
cat > config.py <<'EOF'
# Application configuration

APP_NAME = "Thuta Widgets"
VERSION = "1.0.0"
DEBUG = False
EOF
git add config.py
git commit -m "Initial commit: add config.py"
git push origin main
cd ..

# Two developers each clone the same team repo
git clone /tmp/team-repo.git /tmp/dev-a
git clone /tmp/team-repo.git /tmp/dev-b

# --- Developer A ---
cd /tmp/dev-a
export GIT_AUTHOR_NAME="Developer Aye"
export GIT_AUTHOR_EMAIL="aye@example.com"
export GIT_COMMITTER_NAME="Developer Aye"
export GIT_COMMITTER_EMAIL="aye@example.com"
export GIT_AUTHOR_DATE="2026-02-01T10:00:00"
export GIT_COMMITTER_DATE="2026-02-01T10:00:00"
sed -i 's/DEBUG = False/DEBUG = True  # enabled for local testing/' config.py
git add config.py
git commit -m "Enable DEBUG mode for local testing"
git push origin main   # succeeds: fast-forward

# --- Developer B (has not seen A's push yet) ---
cd /tmp/dev-b
export GIT_AUTHOR_NAME="Developer Banyar"
export GIT_AUTHOR_EMAIL="banyar@example.com"
export GIT_COMMITTER_NAME="Developer Banyar"
export GIT_COMMITTER_EMAIL="banyar@example.com"
export GIT_AUTHOR_DATE="2026-02-01T10:30:00"
export GIT_COMMITTER_DATE="2026-02-01T10:30:00"
sed -i 's/DEBUG = False/DEBUG = os.environ.get("DEBUG", "false") == "true"/' config.py
git add config.py
git commit -m "Read DEBUG flag from environment variable"
git push origin main   # REJECTED -- remote has A's commit

git pull origin main   # fetch + merge -> real CONFLICT
cat config.py           # shows <<<<<<< / ======= / >>>>>>> markers

# Resolve by hand: pick the final version, remove every marker
cat > config.py <<'EOF'
# Application configuration

APP_NAME = "Thuta Widgets"
VERSION = "1.0.0"
DEBUG = os.environ.get("DEBUG", "true") == "true"  # defaults on for local dev
EOF
git add config.py
git commit -m "Merge branch 'main' of team-repo, resolve DEBUG conflict"
git push origin main   # succeeds now
You should see
Developer A's push succeeds cleanly:

   4a5335c..75007aa  main -> main

Developer B's push is rejected outright:

 ! [rejected]        main -> main (fetch first)
 error: failed to push some refs to '...team-repo.git'
 hint: Updates were rejected because the remote contains work that you do
 hint: not have locally...

Running git pull as Developer B fetches A's commit and attempts an automatic merge, which fails on the same line:

Auto-merging config.py
CONFLICT (content): Merge conflict in config.py
Automatic merge failed; fix conflicts and then commit the result.

git status confirms it:

Unmerged paths:
  (use "git add <file>..." to mark resolution)
	both modified:   config.py

Opening config.py shows Git's real conflict markers, both versions side by side:

<<<<<<< HEAD
DEBUG = os.environ.get("DEBUG", "false") == "true"
=======
DEBUG = True  # enabled for local testing
>>>>>>> 75007aa66326065e94a973a8f81a152cd9144142

After editing the file by hand to combine both intents, staging it, and committing the merge, the final push succeeds:

   75007aa..83b66b7  main -> main

git log --graph then shows both developers' commits preserved side by side under one merge commit.

5-minute try-it

Repeat the scenario but this time have both developers create their edits on separate feature branches (dev-a/fix-debug and dev-b/fix-debug) instead of committing straight to main, open both as separate pull requests conceptually, merge Developer A's branch to main first, then try merging Developer B's branch and observe whether the conflict still happens, just at merge time instead of push time.

One important caution

Committing straight to main instead of a branch, so a conflict blocks the whole team's shared history instead of being isolated to one pull request.

Force-pushing (git push --force) to make the rejected push 'go through' instead of pulling and resolving the conflict -- this can silently overwrite a teammate's already-pushed commit.

GitHub Docs: Resolving a merge conflict using the command lineGit & GitHub

Easy traps

  • Committing straight to main instead of a branch, so a conflict blocks the whole team's shared history instead of being isolated to one pull request.
  • Force-pushing (git push --force) to make the rejected push 'go through' instead of pulling and resolving the conflict -- this can silently overwrite a teammate's already-pushed commit.
  • Always run `git status` before any destructive or history-rewriting command, to know exactly what state you're in.

Exercise

Repeat the scenario but this time have both developers create their edits on separate feature branches (dev-a/fix-debug and dev-b/fix-debug) instead of committing straight to main, open both as separate pull requests conceptually, merge Developer A's branch to main first, then try merging Developer B's branch and observe whether the conflict still happens, just at merge time instead of push time.

You'll know it worked when: Developer A's push succeeds cleanly: 4a5335c..75007aa main -> main Developer B's push is rejected outright: ! [rejected] main -> main (fetch first) error: failed to push some refs to '...team-repo.git' hint: Updates were rejected because the remote contains work that you do hint: not have locally... Running git pull as Developer B fetches A's commit and attempts an automatic merge, which fails on the same line: Auto-merging config.py CONFLICT (content): Merge conflict in config.py Automatic merge failed; fix conflicts and then commit the result. git status confirms it: Unmerged paths: (use "git add <file>..." to mark resolution) both modified: config.py Opening config.py shows Git's real conflict markers, both versions side by side: <<<<<<< HEAD DEBUG = os.environ.get("DEBUG", "false") == "true" ======= DEBUG = True # enabled for local testing >>>>>>> 75007aa66326065e94a973a8f81a152cd9144142 After editing the file by hand to combine both intents, staging it, and committing the merge, the final push succeeds: 75007aa..83b66b7 main -> main git log --graph then shows both developers' commits preserved side by side under one merge commit.