fix(git): replace branch-per-iteration with commit-per-iteration (v0.8.1)#118
fix(git): replace branch-per-iteration with commit-per-iteration (v0.8.1)#118
Conversation
…tilities New git utility functions for the commit-per-iteration loop strategy: - getCurrentCommitSha: reads HEAD SHA for pre-iteration snapshots - commitAllChanges: stages all, commits if changes exist, returns SHA or null - resetToCommit: validates SHA format, runs git reset --hard + git clean -fd Also updates ROADMAP.md to mark v0.8.0 as released and add v0.8.1 entry. Co-Authored-By: Claude <noreply@anthropic.com>
…mmitSha fields New fields for commit-per-iteration strategy: - Loop.gitStartCommitSha: captures HEAD at loop creation for revert target - LoopIteration.gitCommitSha: commit SHA after successful iteration - LoopIteration.preIterationCommitSha: snapshot before iteration starts Old fields (gitBaseBranch, gitBranch on iteration) kept for migration safety. Co-Authored-By: Claude <noreply@anthropic.com>
…umns Migration 12 adds git_start_commit_sha to loops table and git_commit_sha, pre_iteration_commit_sha to loop_iterations table. Repository updated to read/write new columns in all prepared statements and row conversion helpers. Co-Authored-By: Claude <noreply@anthropic.com>
…tion Core behavioral change for git integration: - Branch created once on first iteration only (not per-iteration) - preIterationCommitSha captured before each iteration starts - Pass/keep path: commitAllChanges + captureGitDiff for diff summary - Fail/discard/crash path: resetToCommit to appropriate target - getResetTargetSha helper: optimize uses best iteration, retry uses start - All git operations wrapped in try/catch for graceful degradation Co-Authored-By: Claude <noreply@anthropic.com>
…-handler Both loop creation paths now capture gitStartCommitSha via getCurrentCommitSha: - LoopManagerService.createLoop: always captures when in a git repo - ScheduleHandler.handleLoopTrigger: same pattern for scheduled loops Updated schedule-handler tests for the new always-capture behavior. Co-Authored-By: Claude <noreply@anthropic.com>
MCP adapter: adds gitStartCommitSha to loop status response, adds gitCommitSha and preIterationCommitSha to iteration responses. CLI: shows short SHA for Git Start (8 chars), iteration display shows commit SHA instead of branch name. Co-Authored-By: Claude <noreply@anthropic.com>
Corrects v0.8.0 release notes to remove branch-per-iteration language. Adds v0.8.1 changelog entry documenting the commit-per-iteration fix. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Fix biome formatting in git-state.ts (function signatures) and loop-handler.ts (createAndCheckoutBranch call). Fix TypeScript readonly property assignment errors in loop-manager.ts and schedule-handler.ts by using spread syntax in updateLoop calls. Co-Authored-By: Claude <noreply@anthropic.com>
…prove comments - Extract duplicated git commit+diff logic into commitAndCaptureDiff() helper - Remove unused currentIteration parameter from getResetTargetSha() - Clarify gitBaseBranch as legacy field in comments
…tory, loop-manager - loop-handler: 9 tests covering commit-per-iteration, branch creation on first iteration only, preIterationCommitSha capture, resetToCommit on fail/discard/crash, agent-already-committed fallback - loop-repository: 4 tests for gitStartCommitSha, gitCommitSha, and preIterationCommitSha round-trip persistence and undefined defaults - loop-manager: 4 tests for auto-capture of gitStartCommitSha in git repos, gitBranch + gitBaseBranch with --git-branch, non-git fallback - Rename captureGitDiff params fromBranch/toBranch to fromRef/toRef to reflect that commit SHAs are now accepted (not just branch names) Co-Authored-By: Claude <noreply@anthropic.com>
| @@ -733,12 +766,30 @@ export class LoopHandler extends BaseEventHandler { | |||
| */ | |||
There was a problem hiding this comment.
🔴 Blocking: Duplicated git commit logic in handleRetryResult vs recordAndContinue
The retry pass path in handleRetryResult duplicates the git commit/diff capture logic that also exists in recordAndContinue. Both paths:
- Call
commitAndCaptureDiff - Handle errors identically
- Produce the same
gitCommitShaandgitDiffSummaryvariables
The only reason handleRetryResult has its own copy is the atomic transaction for iteration pass + loop completion, but this creates a maintenance burden. If commit logic changes (e.g., adding verification), both places must be updated in lockstep.
Suggested fix: Extract the git pre-persist step into a shared helper, or refactor handleRetryResult to reuse recordAndContinue with a post-action callback for loop completion. At minimum, add a comment cross-referencing:
// NOTE: This git logic is duplicated in recordAndContinue() — keep both in syncFlagged by: consistency, complexity, architecture reviewers (82-85% confidence)
| if (branchResult.ok) { | ||
| iterationGitBranch = branchName; | ||
| this.logger.info('Created git branch for iteration', { | ||
| // Git commit-per-iteration (v0.8.1) |
There was a problem hiding this comment.
This method grew from ~30 lines to 96 lines with git integration. It now handles:
- atomic iteration increment
- git branch creation on first iteration
- git branch re-checkout on subsequent iterations
- pre-iteration SHA capture
- dispatch to single-task vs pipeline iteration
The result: 13 decision points with max nesting depth 4 levels.
Suggested fix: Extract the git setup block (lines 510-562) into a private helper method like setupGitForIteration(loop, iterationNumber): Promise<string | undefined>. This would reduce startNextIteration to ~50 lines and bring branching down to ~5.
private async setupGitForIteration(
loop: Loop,
iterationNumber: number,
): Promise<string | undefined> {
if (iterationNumber === 1 && loop.gitBranch) {
const branchResult = await createAndCheckoutBranch(
loop.workingDirectory, loop.gitBranch, loop.gitBaseBranch,
);
if (!branchResult.ok) {
this.logger.warn('Failed to create git branch for loop, continuing without git', { ... });
}
} else if (iterationNumber > 1 && loop.gitBranch) {
const checkoutResult = await createAndCheckoutBranch(loop.workingDirectory, loop.gitBranch);
if (!checkoutResult.ok) {
this.logger.warn('Failed to re-checkout loop branch, continuing without git', { ... });
}
}
const shaResult = await getCurrentCommitSha(loop.workingDirectory);
if (shaResult.ok) return shaResult.value;
this.logger.warn('Failed to capture pre-iteration commit SHA', { ... });
return undefined;
}Flagged by: complexity reviewer (90% confidence)
| gitDiffSummary = gitResult.gitDiffSummary; | ||
| } else { | ||
| // Discard path: reset to the appropriate target | ||
| const resetTarget = await this.getResetTargetSha(loop); |
There was a problem hiding this comment.
The git revert path (lines 1098-1112) has excessive nesting depth. The deepest path is:
method body → if preIterationCommitSha → try → else (discard path) → if resetTarget → if !resetResult.ok
Per complexity-patterns, nesting depth >4 is critical-warning territory.
Suggested fix: Extract the revert path into a separate helper method to flatten nesting:
// In recordAndContinue, replace the else-block with:
} else {
await this.revertIterationChanges(loop, iteration);
}
private async revertIterationChanges(loop: Loop, iteration: LoopIteration): Promise<void> {
const resetTarget = await this.getResetTargetSha(loop);
if (!resetTarget) return;
const resetResult = await resetToCommit(loop.workingDirectory, resetTarget);
if (!resetResult.ok) {
this.logger.warn('Failed to reset to commit after iteration failure', {
loopId: loop.id,
iterationNumber: iteration.iterationNumber,
resetTarget,
error: resetResult.error.message,
});
}
}Flagged by: complexity reviewer (88% confidence)
| expect(vi.mocked(createAndCheckoutBranch)).toHaveBeenCalledWith('/tmp', 'feat/loop-work', 'main'); | ||
| }); | ||
|
|
||
| it('should NOT call createAndCheckoutBranch for subsequent iterations', async () => { |
There was a problem hiding this comment.
The test wraps its core assertion in if (calls.length > 0), which makes the test vacuously true if createAndCheckoutBranch is never called for iteration 2 (a regression).
The test name says "should NOT call createAndCheckoutBranch" but the actual behavior (per the comment) is that it SHOULD call it with different args (no baseBranch). The conditional assertion means if the call is missing, the test silently passes without catching the regression.
Suggested fix: Rename the test to accurately describe expected behavior and replace the conditional with definitive assertions:
it('should re-checkout loop branch without baseBranch for subsequent iterations', async () => {
// ... setup ...
vi.mocked(createAndCheckoutBranch).mockClear();
const taskId1 = await getLatestTaskId(loop.id);
await eventBus.emit('TaskCompleted', { taskId: taskId1!, exitCode: 0, duration: 100 });
await flushEventLoop();
const calls = vi.mocked(createAndCheckoutBranch).mock.calls;
expect(calls).toHaveLength(1); // Exactly one re-checkout call
expect(calls[0][2]).toBeUndefined(); // No fromRef for re-checkout
});Flagged by: tests reviewer (90% confidence)
| gitBaseBranch = gitStateResult.value.branch; | ||
| let gitStartCommitSha: string | undefined; | ||
| const gitStateResult = await captureGitState(validatedWorkingDirectory); | ||
| if (gitStateResult.ok && gitStateResult.value) { |
There was a problem hiding this comment.
Both files contain nearly identical git state capture logic:
- Call
captureGitState - Conditionally extract
gitBaseBranch - Call
getCurrentCommitSha - Conditionally extract
gitStartCommitSha - Spread-merge into loop update
This is a DRY violation. If the git capture strategy evolves (e.g., adding a new field, changing capture order), both call sites must be updated in lockstep.
Suggested fix: Extract a shared utility function:
// in src/utils/git-state.ts
export async function captureGitContextForLoop(
workingDirectory: string,
gitBranch?: string,
): Promise<{ gitBaseBranch?: string; gitStartCommitSha?: string }> {
const gitStateResult = await captureGitState(workingDirectory);
if (!gitStateResult.ok || !gitStateResult.value) return {};
const gitBaseBranch = gitBranch ? gitStateResult.value.branch : undefined;
const shaResult = await getCurrentCommitSha(workingDirectory);
const gitStartCommitSha = shaResult.ok ? shaResult.value : undefined;
return { gitBaseBranch, gitStartCommitSha };
}Then both callers reduce to:
const gitCtx = await captureGitContextForLoop(dir, request.gitBranch);
const loopWithGit = (gitCtx.gitBaseBranch || gitCtx.gitStartCommitSha)
? updateLoop(loop, gitCtx)
: loop;Flagged by: architecture, complexity, performance reviewers (82-85% confidence)
Confidence Score: 3/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant U as User/Agent
participant LM as LoopManager
participant LH as LoopHandler
participant Git as git-state utils
participant DB as SQLiteLoopRepository
U->>LM: createLoop(request)
LM->>Git: captureGitState(workingDir)
Git-->>LM: { branch, commitSha }
LM->>Git: getCurrentCommitSha(workingDir)
Git-->>LM: startCommitSha
LM->>DB: emit LoopCreated (with gitStartCommitSha)
loop Each iteration
LH->>LH: isGitLoop = !!(gitBranch ∥ gitStartCommitSha)
opt iterationNumber === 1 AND gitBranch set
LH->>Git: createAndCheckoutBranch(dir, branch, baseBranch)
end
LH->>Git: getCurrentCommitSha(workingDir)
Git-->>LH: preIterationCommitSha
LH->>DB: recordIteration(preIterationCommitSha)
U->>LH: TaskCompleted / TaskFailed
alt eval passes (retry=pass, optimize=keep)
LH->>Git: commitAllChanges(dir, message)
Git-->>LH: gitCommitSha (or null if agent already committed)
opt gitCommitSha is null
LH->>Git: getCurrentCommitSha(workingDir)
Git-->>LH: gitCommitSha (fallback)
end
LH->>Git: captureGitDiff(dir, preIterationSHA, gitCommitSha)
Git-->>LH: diffSummary
LH->>DB: updateIteration(gitCommitSha, gitDiffSummary, status=pass/keep)
else eval fails (retry=fail, optimize=discard, crash)
LH->>LH: getResetTargetSha(loop)
note over LH: OPTIMIZE: bestIteration.gitCommitSha<br/>RETRY / fallback: loop.gitStartCommitSha
LH->>Git: resetToCommit(dir, targetSha)
note over Git: git reset --hard sha<br/>git clean -fd
LH->>DB: updateIteration(status=fail/discard/crash)
end
end
Reviews (1): Last reviewed commit: "test: add git integration test coverage ..." | Re-trigger Greptile |
| let gitStartCommitSha: string | undefined; | ||
| const gitStateResult = await captureGitState(validatedWorkingDirectory); | ||
| if (gitStateResult.ok && gitStateResult.value) { | ||
| gitBaseBranch = request.gitBranch ? gitStateResult.value.branch : undefined; | ||
| // Always capture start commit SHA in git repos | ||
| const shaResult = await getCurrentCommitSha(validatedWorkingDirectory); | ||
| if (shaResult.ok) { | ||
| gitStartCommitSha = shaResult.value; | ||
| } |
There was a problem hiding this comment.
Git operations now activate for all loops in git repos (not just
--git-branch)
gitStartCommitSha is now captured unconditionally whenever the working directory is a git repository, regardless of whether --git-branch was specified:
// Always capture start commit SHA in git repos
const shaResult = await getCurrentCommitSha(validatedWorkingDirectory);
if (shaResult.ok) {
gitStartCommitSha = shaResult.value;
}Because isGitLoop = !!(updatedLoop.gitBranch || updatedLoop.gitStartCommitSha) in loop-handler.ts, and because gitStartCommitSha is now always populated in git repos, every loop running inside a git repository will now:
- Run
git add -A && git commiton every pass/keep iteration - Run
git reset --hard <sha> && git clean -fdon every fail/discard/crash
The git clean -fd call in particular permanently destroys untracked (but non-ignored) files that the user created during the iteration — with no warning, and even when the user did not opt into git integration via --git-branch.
This conflicts with the CLI/docs framing of --git-branch as an opt-in flag. A user running beat loop create "refactor X" --exit-condition ./check.sh in a git repo with untracked WIP files will silently lose them on the first failed iteration.
If the intent is to always track git state in git repos, this should be explicitly documented and communicated as an opt-out (or the destructive revert path should be gated on updatedLoop.gitBranch explicitly). Consider guarding the commit/reset behavior with a check like:
const isCommitLoop = !!updatedLoop.gitBranch; // only when user opts inand keeping gitStartCommitSha purely for informational SHA recording.
| /** | ||
| * Git state capture and branch management utilities | ||
| * Git state capture, branch management, and commit utilities | ||
| * ARCHITECTURE: Pure functions returning Result, uses execFile for security (no shell injection) | ||
| */ |
There was a problem hiding this comment.
Missing release notes file (CLAUDE.md release process)
CLAUDE.md specifies that creating a release notes file is REQUIRED before any release:
Create release notes (REQUIRED): Must match version in
package.json
The package version is bumped to 0.8.1, but there is no corresponding docs/releases/RELEASE_NOTES_v0.8.1.md file in this PR (only RELEASE_NOTES_v0.8.0.md exists). The release workflow validates that this file exists before publishing to npm, so the release will fail without it.
| const fromValidation = validateGitRefName(fromRef, 'ref'); | ||
| if (!fromValidation.ok) return fromValidation; | ||
|
|
||
| const toValidation = validateGitRefName(toBranch, 'branch'); | ||
| const toValidation = validateGitRefName(toRef, 'ref'); | ||
| if (!toValidation.ok) return toValidation; |
There was a problem hiding this comment.
captureGitDiff uses branch-name validator for commit SHA inputs
captureGitDiff now accepts commit SHAs as fromRef/toRef (called from commitAndCaptureDiff with values like iteration.preIterationCommitSha), but it validates them with validateGitRefName, which is designed for branch names.
Hex SHAs happen to pass this validator (they contain no spaces, .., @{, etc.), so there is no functional bug today. However, isValidCommitSha is already defined in the same file and is the purpose-built validator for this input type. Using the wrong validator is a semantic inconsistency that could silently permit inputs that are valid branch names but not valid SHAs (e.g. my-feature), and it'll be a maintenance pitfall if SHA-specific checks are ever added.
Consider accepting a union type or using isValidCommitSha (if exported) for the ref validation path when the caller provides commit SHAs.
Summary of Code Review FindingsPR #118: Commit-per-iteration git integration for Backbeat v0.8.1 Overall Assessment10 reviewers analyzed this PR across architecture, complexity, consistency, database, dependencies, documentation, performance, regression, security, and test quality. The commit-per-iteration design is sound. 4 blocking issues (80%+ confidence) were found, all with straightforward fixes outlined in the inline comments above. Inline Comments CreatedFour critical-path comments were created on changed code:
Lower-Confidence Suggestions (60-79%)These don't block merge but warrant discussion: Performance & Design (60-75% confidence)
Test Coverage Gaps (60-85% confidence)
Documentation Issues (80-95% confidence)
Pre-Existing Issues (Not Blocking)
Reviewer Scores
To MergeAddress the 4 blocking inline comments (code changes required):
Recommended before merge:
Git Code Review Footer: This review was generated by Claude Code analyzing 10 reviewer reports across architecture, complexity, consistency, database, dependencies, documentation, performance, regression, security, and testing dimensions. Confidence: inline comments 80%+, summary 60%+. |
Summary
v0.8.0 shipped with a broken branch-per-iteration git strategy. This patch replaces it with a single-branch commit-per-iteration design that's simpler, safer, and more efficient.
Design Changes
task-123-optimization)Implementation Details
Testing
Related Issues
Fixes: v0.8.0 git integration regression