* test(#4055): full-lifecycle regression for merged-branch resurrection * fix(#4055): verify a phase branch is genuinely new before create-and-switch * test(#4055): assert branch absence via the gitOrThrow throw contract * test(#4055): observe the refusal disclosure via the process seam * chore(#4055): add changeset fragment * test(#4055): drop an unused fixture variable * fix(#4055): name the milestone-arm residual and the guard degradations --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -1985,6 +1985,10 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
|
||||
const branchingStrategy = config['branching_strategy'] as string | undefined;
|
||||
if (branchingStrategy && branchingStrategy !== 'none') {
|
||||
let branchName: string | null = null;
|
||||
// #4055: the phase directory (cwd-relative POSIX path from
|
||||
// findPhaseInternal) captured while resolving the phase identity — the
|
||||
// state-3 guard below needs it for the committed-history check.
|
||||
let phaseDirRelative: string | null = null;
|
||||
if (branchingStrategy === 'phase') {
|
||||
// Determine which phase we're committing for from the file paths.
|
||||
// #2539: the extraction is anchored to the directory SEGMENT immediately
|
||||
@@ -2015,6 +2019,10 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
|
||||
phaseInfo['phase_number'],
|
||||
phaseInfo['phase_slug'],
|
||||
);
|
||||
// #4055: findPhaseInternal already returns the directory as a
|
||||
// cwd-relative POSIX path.
|
||||
const dir = phaseInfo['directory'];
|
||||
if (typeof dir === 'string' && dir !== '') phaseDirRelative = dir;
|
||||
}
|
||||
}
|
||||
} else if (branchingStrategy === 'milestone') {
|
||||
@@ -2038,6 +2046,14 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
|
||||
}
|
||||
}
|
||||
if (branchName) {
|
||||
// #4055: state-3 discriminator for the create arm. `rev-parse --verify`
|
||||
// alone cannot distinguish "branch never existed" (create is the #1278
|
||||
// intent) from "branch existed, was merged, then deleted" (the phase is
|
||||
// over — recreating it hijacks the close-out commit onto a resurrected
|
||||
// ref, the #3079 bug #3363 reopened). Both extra conditions come from
|
||||
// the confirmed issue: the create arm may fire only for a phase whose
|
||||
// directory has NO committed history on the current line (a genuinely
|
||||
// new phase) while the caller sits on the resolved base branch.
|
||||
const currentBranch = execGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd });
|
||||
if (currentBranch.exitCode === 0 && currentBranch.stdout.trim() !== branchName) {
|
||||
// #2539/#3079/#3207: two cases the prior (#3079) code collapsed into one.
|
||||
@@ -2052,20 +2068,71 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
|
||||
// EXISTING branch is never switched to (the else arm logs + commits in
|
||||
// place). The fresh create is logged so the first phase-scoped commit is
|
||||
// not silent about where the work is landing (#3207 AC3).
|
||||
// #4055: "brand-new" is now VERIFIED, not assumed — see the state-3
|
||||
// guard between the verify and the create below.
|
||||
const verify = execGit(['rev-parse', '--verify', `refs/heads/${branchName}`], { cwd });
|
||||
if (verify.exitCode !== 0) {
|
||||
// Branch does not exist — CREATE AND SWITCH (the #1278 first-commit
|
||||
// case). checkout -b cannot resurrect anything: the branch was just
|
||||
// verified absent, so it is created fresh at HEAD.
|
||||
const create = execGit(['checkout', '-b', branchName], { cwd });
|
||||
if (create.exitCode === 0) {
|
||||
process.stderr.write(
|
||||
`${branchingStrategy} branch "${branchName}" created; switched to it for this commit.\n`
|
||||
// Branch does not exist — but absence alone cannot distinguish a
|
||||
// genuinely new phase from a merged-and-deleted one (#4055).
|
||||
let createBlockReason: string | null = null;
|
||||
if (branchingStrategy === 'phase' && phaseDirRelative) {
|
||||
// #4055 residual: searchPhaseInDir's #2237 fail-safe can return an
|
||||
// empty `directory` for ambiguous phase names (leaving
|
||||
// phaseDirRelative null) — there the history half is skipped and
|
||||
// only the base check below guards; shallow clones can also show
|
||||
// an empty probe for old merged phases (depth-sensitive).
|
||||
const history = execGit(
|
||||
['log', 'HEAD', '--oneline', '--', phaseDirRelative],
|
||||
{ cwd },
|
||||
);
|
||||
if (history.exitCode === 0 && history.stdout.trim() !== '') {
|
||||
createBlockReason =
|
||||
'its phase directory already has committed history (the phase is resolved)';
|
||||
}
|
||||
}
|
||||
if (!createBlockReason) {
|
||||
// The base half of the guard applies to BOTH strategies (it does
|
||||
// not need a directory): a phase/milestone branch is created only
|
||||
// from the resolved base branch. NOTE the milestone arm keeps its
|
||||
// existence-only guard for the HISTORY half — a merged-and-deleted
|
||||
// milestone branch remains resurrectable by an on-base caller
|
||||
// until a milestone-directory derivation exists here (#4055
|
||||
// follow-up candidate).
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const gitBaseBranch = require('./git-base-branch.cjs') as {
|
||||
resolveBaseBranch: (cwd: string) => string;
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
const resolvedBase = gitBaseBranch.resolveBaseBranch(cwd);
|
||||
if (resolvedBase && resolvedBase !== currentBranch.stdout.trim()) {
|
||||
createBlockReason =
|
||||
`the current branch "${currentBranch.stdout.trim()}" is not the ` +
|
||||
`resolved base branch "${resolvedBase}"`;
|
||||
}
|
||||
}
|
||||
if (createBlockReason === null) {
|
||||
// State 1 confirmed: brand-new phase, first phase-scoped commit
|
||||
// from the base branch. CREATE AND SWITCH (the #1278 first-commit
|
||||
// case). checkout -b cannot resurrect anything: the branch was
|
||||
// just verified absent, so it is created fresh at HEAD.
|
||||
const create = execGit(['checkout', '-b', branchName], { cwd });
|
||||
if (create.exitCode === 0) {
|
||||
process.stderr.write(
|
||||
`${branchingStrategy} branch "${branchName}" created; switched to it for this commit.\n`
|
||||
);
|
||||
} else {
|
||||
process.stderr.write(
|
||||
`Warning: could not create ${branchingStrategy} branch "${branchName}" ` +
|
||||
`(${create.stderr.trim()}); committing on the current branch "${currentBranch.stdout.trim()}".\n`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// State 3 (or a non-base caller): the phase is resolved — commit
|
||||
// in place, disclosed (#2539 AC2), never recreate the branch.
|
||||
process.stderr.write(
|
||||
`Warning: could not create ${branchingStrategy} branch "${branchName}" ` +
|
||||
`(${create.stderr.trim()}); committing on the current branch "${currentBranch.stdout.trim()}".\n`
|
||||
`Warning: resolved ${branchingStrategy} branch "${branchName}" is absent and ` +
|
||||
`will not be recreated (${createBlockReason}); committing on the current ` +
|
||||
`branch "${currentBranch.stdout.trim()}" instead of recreating it.\n`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user