Skip to main content

Command Palette

Search for a command to run...

When Tasks Branch With the Code

Updated
14 min readView as Markdown
When Tasks Branch With the Code
V
I’m software engineer with 15+ years of experience building complex developer tools, data-processing systems, graphics platforms, cloud-connected applications and cross-platform products.

Once tasks live in the repository, something strange happens. They stop behaving like tickets. They start behaving like project state. At first this seems like a small difference.

A task is still a task. It still has a title. It still has a status. Someone still works on it. But now it lives beside the code. So when the code branches, the task branches too.

That changes more than I expected.

A branch is not only code

Suppose we have a task:

TASK-042

It says:

Retry failed uploads after temporary network errors.

A developer creates a branch.

git switch -c retry-failed-uploads

Nothing unusual yet. The source code branches. The tests branch. The documentation branches. And because the task is a file, the task branches too. This matters because tasks change during implementation. They almost always do. The original description is rarely perfect. You discover that one acceptance criterion is wrong. You find another edge case. You realize the implementation depends on work that nobody planned.

You split the task. You add notes. You mark part of it as finished. You discover that the bug is not where everyone thought it was.

In a traditional tracker, these updates happen in a separate system. The branch evolves in Git. The task evolves somewhere else. The developer has to keep the two in sync. Sometimes they do. Sometimes they forget. Sometimes the task is updated before the code. Sometimes after. Sometimes never.

When the task lives on the branch, the situation is simpler. The understanding of the work changes in the same place as the work itself.

The task can evolve with the implementation

Imagine the task starts like this:

---
id: TASK-042
status: doing
assignee: alice
---

# Retry failed uploads

Retry temporary network failures using exponential backoff.

## Acceptance criteria

- Temporary failures are retried.
- Permanent failures fail immediately.
- Retry behaviour has tests.

Halfway through implementation, Alice discovers something. The server does not provide enough information to distinguish all permanent failures. That changes the task.

So she updates it.

## Implementation notes

The current API does not distinguish all permanent failures.

Add a follow-up task for improving server error classification.

She also creates:

TASK-057.md

The branch now contains:

src/uploader.cpp
tests/uploader_tests.cpp
tasks/TASK-042.md
tasks/TASK-057.md

This feels natural. The implementation changed the understanding of the project. So the project state changed too. There is no second system to update. There is no task synchronization protocol. There is no question about whether the ticket reflects the branch. It does.

Because it is on the branch.

What happens if the branch dies?

This is where the model gets interesting. Suppose the experiment fails. The implementation is abandoned. The branch is deleted.

What should happen to the task updates created during that experiment?

In a traditional tracker, they may already have become global project state. A follow-up issue may have been created. The task may say that a particular approach was chosen. Then the branch is abandoned.

Now the tracker contains decisions from a version of reality that never entered the project.

With repository-native tasks, those changes can die with the branch. That can be exactly what you want. The branch was an experiment. Its project state was experimental too. Nothing becomes part of the main project until the branch is merged. This is a useful property. It means a branch can represent not only an alternative implementation. It can represent an alternative understanding of the work.

And if the branch succeeds?

Then the task changes are merged with the code.

A pull request might contain:

src/uploader.cpp
src/retry_policy.cpp
tests/uploader_tests.cpp
tasks/TASK-042.md
tasks/TASK-057.md

The reviewer now sees more than the implementation. They can see that the task changed. They can see why it changed. They can see that a new task was created. They can challenge all of it.

Maybe the code is correct but the new follow-up task is unnecessary. Maybe the implementation exposed a requirement that should be part of the current work. Maybe the original acceptance criteria should not be changed just to match the implementation.

This becomes part of code review. That sounds obvious once the files are in the same diff. But it is very different from the usual workflow.

Review the work and the understanding of the work

Code review normally answers questions like:

Is this implementation correct?

Is it maintainable?

Are the tests sufficient?

Does it introduce a regression?

But another important question often sits somewhere else:

Did we solve the right problem?

That question belongs to the task. If the task and code are reviewed separately, it is easy to miss the gap between them. If they are part of one change set, the gap becomes visible.

A reviewer can see:

- Retry all failed requests.
+ Retry only failures classified as temporary.

beside the code implementing the new rule.

That is useful. The requirement and the implementation can be challenged together. The task is no longer merely a pointer to the work. It becomes part of the change.

The commit becomes richer

The same thing happens at the commit level.

A commit might contain:

src/auth.cpp
tests/auth_test.cpp
tasks/TASK-042.md

Now one commit says three things at once.

What changed in the software. How we verified it. What changed in our understanding of the work.

That is a much stronger historical record.

Imagine looking at an old commit and seeing:

Fix token refresh race

- serialize concurrent refresh attempts
- add regression test
- update TASK-042 with discovered failure mode

The code tells you what changed. The task tells you why the shape of the work changed. And because they share a commit, you know they belonged together. This is something external trackers struggle to preserve.

They can link a commit to a task. That is useful. But a link is not the same as shared history.

Putting files in the same commit makes them part of the same event. That difference matters.

A link says:

These two things are related.

A commit says:

These two things changed together.

That is stronger. Suppose the implementation changes a configuration file, a test, and a task. The commit captures the whole state transition. Before the commit, the project was one thing. After the commit, it was another. This includes both product state and work state.

The project history becomes more complete.

Then comes the uncomfortable part

Tasks are files. Files can conflict. Suppose Alice and Bob both change TASK-042.md.

Alice changes:

status: review

Bob changes:

assignee: bob

Git may merge this automatically.

Good.

But perhaps both change the same line.

Alice writes:

status: done

Bob writes:

status: blocked

Now Git reports a conflict. At first this looks like a serious weakness.

Traditional task trackers do not usually show merge conflict markers.

Nobody wants this:

<<<<<<< HEAD
status: done
=======
status: blocked
>>>>>>> other-branch

inside their task.

But the conflict itself is telling us something. Alice believes the task is done. Bob believes the task is blocked. That is not merely a technical conflict. It is a disagreement about project state.

Maybe hiding it would be worse.

What does a task conflict actually mean?

This question turns out to be more interesting than the mechanics of resolving it.

Consider two people editing a description. One changes an acceptance criterion. Another removes it.

Git says: there are two incompatible versions of this document.

A traditional tracker usually avoids this by serializing edits. One person saves first. Another saves later. The second version wins. Perhaps there is an edit history. Perhaps there is not.

The database stays consistent. But does the project?

The disagreement still exists. It is just less visible.

Git makes it explicit.

That can be annoying. It can also be valuable. A merge conflict is not always a failure of the storage model. Sometimes it is the storage model correctly telling you that two branches disagree about reality.

Some conflicts are boring

Not every conflict has philosophical depth. Sometimes two people just edited adjacent lines. Sometimes formatting changed. Sometimes a tool reordered metadata. Those conflicts should be minimized.

A good task format matters here. Stable field ordering helps. One task per file helps. Avoiding large generated sections helps. Keeping project-wide data out of each task helps. A tool can also provide a nicer merge experience.

It can understand:

status
assignee
labels
description

as separate pieces instead of presenting raw conflict markers.

Git provides the conflict detection. The application can help explain the conflict.

That seems like the right division of responsibility.

Some conflicts are not boring

Imagine this:

Branch A says:

TASK-042: done

Branch B says:

TASK-042: split into TASK-057 and TASK-058

Which one is correct?

There is no automatic answer. The branches describe different project histories. Someone has to decide what the merged project should mean. That is exactly what a merge is for.

We already accept this for code. Two branches implement the same feature differently. Git cannot decide which architecture is correct. A human resolves it.

Why should project state be fundamentally different?

The task follows the branch

This creates another useful property.

Imagine a long-running release branch.

release/2.4

There is also:

main

The two branches may have different bugs. Different priorities. Different unfinished work.

In a centralized tracker, representing this cleanly is hard.

A task often needs labels such as:

affects-2.4
affects-main
backport-needed

The tracker has to model multiple code realities explicitly. But Git already has multiple code realities. If tasks branch with the code, some of that complexity disappears. The release/2.4 branch can contain the tasks relevant to 2.4. main can contain the tasks relevant to future development. Shared tasks can exist in both.

Branch-specific tasks can exist only where they matter. This will not solve every release-management problem. But again, it uses an existing project concept instead of inventing another one.

A branch becomes a project snapshot

This is the larger idea.

When tasks live in Git, a branch is no longer just:

A version of the source tree.

It becomes:

A version of the project.

It contains the implementation. The tests. The configuration. The documentation. The workflow. The tasks.

The branch carries both what the system is and what the team believes should happen to it. That makes branches richer. It also makes them more coherent.

Now check out the past

Git has always been good at project archaeology.

You can run:

git checkout <old-commit>

and see what the source looked like years ago. You can inspect old configuration. Old documentation. Old tests. But if the tasks live elsewhere, something is missing. You can see the old project. You cannot necessarily see the old understanding of the project.

Imagine checking out a commit from March 2024. With repository-native tasks, you may also see:

tasks/
    TASK-031.md
    TASK-032.md
    TASK-038.md

with the statuses they had in March 2024.

Perhaps:

TASK-031  done
TASK-032  doing
TASK-038  todo

You can see what the team thought was finished. What they were working on. What they planned to do next.

That is powerful historical context.

The tracker usually shows the present

This difference is easy to miss.

Suppose a task was created in 2024. It originally said:

Add offline support.

Over the next year the task changes. The scope expands. Comments are added. The title changes. It is split into three tasks. It is eventually closed.

Today, when you open it, you see the final accumulated state. That may be exactly what you want for current project management. But it is not necessarily what you want when examining old code. The old code was written against the old understanding of the task. If you are investigating why a decision was made, the historical version matters.

Git is very good at this.

If the task is a file, you get it for free.

git blame becomes surprisingly useful

Suppose you see this line in a task:

Do not retry authentication failures.

You wonder why.

You can use ordinary Git history.

git blame tasks/TASK-042.md

Then:

git show <commit>

Perhaps the same commit also changed:

src/retry_policy.cpp
tests/auth_failure_test.cpp

Now the reason becomes visible. A requirement changed. The implementation changed with it. The test captured the new behavior. All three belong to the same historical event.

That is a much better answer than:

There was probably a ticket about it.

The repository tells a story

Source control is already a kind of project memory. But it remembers mostly the artifacts we choose to put inside it.

If we store only code, it remembers code. If we store documentation too, it remembers more. If we store architecture decisions, it remembers why some choices were made. If we store tasks, it gains another layer. It remembers intent. Not perfectly. No system does. People still make decisions in meetings. They still forget to write things down. Some discussion stays in chat.

But the distance between intention and implementation becomes smaller.

That matters.

This changes archaeology

Old projects are full of mysteries.

Why does this flag exist? Why was this library chosen? Why does the parser reject a technically valid input? Why was this obvious optimization not implemented?

Often the answer once existed. It lived in Jira. Or Trello. Or an internal system. Or a Slack thread. Then the company migrated tools. Or the project was open sourced without its internal issue history. Or access disappeared.

The source survived. The story did not.

Repository-native tasks improve the odds that both survive together. Clone the old repository and you clone some of its memory. That may become especially important for open source software.

The repository is often the only artifact guaranteed to survive.

It is not a perfect historical record

There is a danger in overstating this.

Git history is not truth. Commits can be squashed. Branches can disappear. History can be rewritten. Tasks can be badly maintained. People can put nonsense in them.

A repository-native task system does not magically create good project discipline. It simply makes project history subject to the same preservation rules as the code. That is already useful.

If your organization values immutable history, preserve it. If you prefer clean squashed commits, accept that some detail will disappear.

The important thing is that the project state follows the same policy as the project itself.

There is another consequence

At this point something becomes hard to ignore.

We started with:

What if a task were a file?

Then tasks gained Git history.

Then they gained branches.

Then they became part of commits.

Then they became reviewable in pull requests.

Then historical checkouts started reconstructing old project state.

None of these were features we had to build. They were consequences of putting the task in the same space as the code. That is the pattern I find most interesting. A good architecture sometimes gives you features accidentally. Not because they are accidental in quality. Because they emerge from the model rather than from a list of requirements. And this model has another consequence.

If a task lives beside the code, anything that can already work with the repository can work with the task.

A text editor can. A shell script can. A code review tool can. And an AI coding agent can.

That removes a boundary that currently causes a surprising amount of friction.

But that is the next part.