Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 86 additions & 1 deletion src/__tests__/e2e/reports-writer-sync-561.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,25 @@ function seedSession(home: string, sessionId: string, timestamp: string): void {
);
}

function rejectReportsPush(origin: string): () => void {
const flag = path.join(origin, 'reject-reports');
const hook = path.join(origin, 'hooks', 'pre-receive');
fs.writeFileSync(hook, `#!/bin/sh
if [ -f "$(dirname "$0")/../reject-reports" ]; then
echo "reports push rejected" >&2
exit 1
fi
exit 0
`);
fs.writeFileSync(flag, '');
fs.chmodSync(hook, 0o755);
return () => fs.rmSync(flag);
}



afterEach(() => {
if (sandbox && path.dirname(sandbox) === os.tmpdir() && path.basename(sandbox).startsWith('teamai-561-')) {
if (sandbox && path.dirname(sandbox) === os.tmpdir() && /^teamai-(561|1277)-/.test(path.basename(sandbox))) {
fs.rmSync(sandbox, { recursive: true, force: true });
}
});
Expand Down Expand Up @@ -160,4 +177,72 @@ describe('real CLI reports writer sync (#561)', () => {
expect(monthLog).toContain('<!-- teamai:session alice-a1 -->');
expect(monthLog).toContain('<!-- teamai:session alice-b1 -->');
}, 60_000);

it('pushes an earlier rejected session summary when retrying an existing session', async () => {
if (!fs.existsSync(cli)) {
throw new Error(`CLI binary not found at ${cli}. Run "npm run build" first.`);
}

sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-1277-'));
const origin = path.join(sandbox, 'origin.git');
const seed = path.join(sandbox, 'seed');
const home = path.join(sandbox, 'home');
const env = machineEnv(home);

fs.mkdirSync(seed, { recursive: true });
fs.writeFileSync(path.join(seed, 'teamai.yaml'), YAML.stringify({
team: 'acme',
repo: origin,
provider: 'git',
}));
git(['init', '-q', '-b', 'main'], seed, env);
git(['add', '.'], seed, env);
git(['commit', '-q', '-m', 'fixture'], seed, env);
git(['clone', '-q', '--bare', seed, origin], sandbox, env);

fs.mkdirSync(path.join(home, '.claude'), { recursive: true });
fs.writeFileSync(
path.join(home, '.gitconfig'),
[
'[user]',
'\tname = alice',
'\temail = alice@example.invalid',
'[protocol "file"]',
'\tallow = always',
'',
].join('\n'),
);
const clone = path.join(home, '.teamai', 'team-repo');
git(['clone', '-q', origin, clone], sandbox, env);
git(['config', 'user.name', 'alice'], clone, env);
git(['config', 'user.email', 'alice@example.invalid'], clone, env);
git(['config', 'protocol.file.allow', 'always'], clone, env);
fs.writeFileSync(path.join(home, '.teamai', 'config.yaml'), YAML.stringify({
repo: { localPath: clone, remote: origin, kind: 'git' },
username: 'alice',
scope: 'user',
updatePolicy: 'skip',
enabledAgents: ['claude'],
additionalRoles: [],
}));

const timestamp = '2026-09-16T12:00:00.000Z';
seedSession(home, 'alice-1277', timestamp);
const allowReportsPush = rejectReportsPush(origin);
const first = await runCli(home, ['session', 'save', '--session-id', 'alice-1277', '--push', '--force', '--scope', 'user'], sandbox);
expect(first.code, first.output).toBe(0);
expect(first.output).toContain('Nothing new to push.');

const rejectedRetry = await runCli(home, ['session', 'save', '--session-id', 'alice-1277', '--push', '--force', '--scope', 'user'], sandbox);
expect(rejectedRetry.code, rejectedRetry.output).toBe(0);
expect(rejectedRetry.output).toContain('Push failed. The summary is saved locally; retry with: teamai session save --push');

allowReportsPush();
const retry = await runCli(home, ['session', 'save', '--session-id', 'alice-1277', '--push', '--force', '--scope', 'user'], sandbox);
expect(retry.code, retry.output).toBe(0);
expect(retry.output).toContain('Pushed a previously unpushed session summary.');

const monthLog = git(['show', 'teamai-reports:sessions/alice/2026-09.md'], origin, env);
expect(monthLog).toContain('<!-- teamai:session alice-1277 -->');
}, 60_000);
});
36 changes: 36 additions & 0 deletions src/__tests__/git-kind-reports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,42 @@ describe('git-kind reports branch', () => {
expect(fs.existsSync(path.join(clone, 'stats', 'alice.yaml'))).toBe(false);
});

it('pushes an earlier unpushed report commit when the retry has no new files', async () => {
const { origin, clone } = await seedBareOrigin();
const cfg = gitConfig(clone, origin);
const wt = await ensureReportsWorktree(cfg);
const memberPath = path.join(wt, 'members', 'alice.yaml');
fs.mkdirSync(path.dirname(memberPath), { recursive: true });
fs.writeFileSync(memberPath, 'username: alice\n');

const rejectFlag = path.join(origin, 'reject-reports');
const hook = path.join(origin, 'hooks', 'pre-receive');
fs.writeFileSync(hook, `#!/bin/sh
if [ -f "$(dirname "$0")/../reject-reports" ]; then
echo "reports push rejected" >&2
exit 1
fi
exit 0
`);
fs.writeFileSync(rejectFlag, '');
fs.chmodSync(hook, 0o755);

expect(await updateReports(cfg, async () => ({
files: ['members/alice.yaml'],
message: '[teamai] Register member: alice',
}))).toMatchObject({ status: 'failed' });
const originGit = simpleGit(origin);
const reportsTreeBeforeRetry = await originGit.raw(['ls-tree', '-r', '--name-only', 'teamai-reports']);
expect(reportsTreeBeforeRetry).not.toContain('members/alice.yaml');

fs.rmSync(rejectFlag);
expect(await updateReports(cfg, async () => ({
files: [],
message: '[teamai] Register member: alice',
}))).toEqual({ status: 'published' });
expect(await originGit.raw(['show', 'teamai-reports:members/alice.yaml'])).toBe('username: alice\n');
}, 30_000);

it('does not copy or delete leftover default-branch members when writing reports', async () => {
const { origin, clone } = await seedBareOrigin();
const leftover = path.join(clone, 'members', 'stale.yaml');
Expand Down
4 changes: 3 additions & 1 deletion src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,9 @@ export async function bootstrapSelfRepo(
const memberDir = path.join(wt, 'members');
await ensureDir(memberDir);
const memberPath = path.join(memberDir, `${username}.yaml`);
if (await pathExists(memberPath)) return null;
if (await pathExists(memberPath)) {
return { files: [], message: `[teamai] Register member: ${username}` };
}
// Absorb the member's pre-switch file from the clone (inherited root):
// its displayName/registeredAt/projects survive the re-registration.
const inherited = await getMemberConfig(localConfig.repo.localPath, username);
Expand Down
14 changes: 12 additions & 2 deletions src/hook-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,12 @@ const votesSyncHandler: HookHandler = {
votesDir,
origin === null ? null : readUserVotesFromContent(origin),
);
if (!staged) return null;
if (!staged) {
return {
files: [],
message: `[teamai] Update votes for ${localConfig.username}`,
};
}
// Deltas are dropped only once origin holds them, and under the
// reports lock, so a concurrent sync cannot stage the same deltas
// between this publish and this clear. A busy lock, a failed
Expand Down Expand Up @@ -800,7 +805,12 @@ const votesJudgeHandler: HookHandler = {
votesDir,
origin === null ? null : readUserVotesFromContent(origin),
);
if (!staged) return null;
if (!staged) {
return {
files: [],
message: `[teamai] Update votes for ${localConfig.username}`,
};
}
onDelivered(() => confirmVotesSynced(votePath, staged.syncedDeltas));
return {
files: [`votes/${localConfig.username}.yaml`],
Expand Down
14 changes: 12 additions & 2 deletions src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1454,7 +1454,12 @@ export async function initSelfRepo(options: GlobalOptions & {
projects: localConfig.projects,
});
selfMemberChanged = merged.changed;
if (!merged.changed) return null;
if (!merged.changed) {
return {
files: [],
message: `[teamai] Update member roster: ${username}`,
};
}
await writeFile(memberPath, YAML.stringify(merged.config));
return {
files: ['members/'],
Expand Down Expand Up @@ -2015,7 +2020,12 @@ export async function init(options: GlobalOptions & {
});
memberChanged = merged.changed;
memberProjects = merged.config.projects;
if (!merged.changed) return null;
if (!merged.changed) {
return {
files: [],
message: `[teamai] Update member roster: ${username}`,
};
}
await writeFile(memberPath, YAML.stringify(merged.config));
return {
files: ['members/'],
Expand Down
12 changes: 8 additions & 4 deletions src/save-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,20 +147,24 @@ export async function saveSession(options: SaveSessionOptions): Promise<void> {
const { isPublished } = await import('./utils/branch-worktree.js');
let rel: string | undefined;
let ran = false;
const pushed = isPublished(await withTimeout(
const result = await withTimeout(
updateReports(localConfig, async (wt) => {
ran = true;
const teamDir = path.join(wt, 'sessions', username);
const written = await appendMonthlyLog(teamDir, summary, { includePrompt: options.includePrompt });
if (!written) return null;
if (!written) return { files: [], message: commitMsg };
rel = path.relative(wt, written);
return { files: [rel], message: commitMsg };
}),
10_000,
'Push timeout (10s)',
));
);
const pushed = isPublished(result);
if (ran && !rel) {
spin.info('Session already present in the team log — nothing to push.');
if (pushed) spin.succeed('Pushed a previously unpushed session summary.');
else if (result.status === 'failed') spin.warn('Push failed. The summary is saved locally; retry with: teamai session save --push');
else if (result.status === 'busy') spin.warn('Team reports branch is busy. The summary is saved locally; retry with: teamai session save --push');
else spin.info('Session already present in the team log — nothing to push.');
return;
}
if (pushed) spin.succeed(`Pushed: ${rel}`);
Expand Down
2 changes: 1 addition & 1 deletion src/team-push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1110,7 +1110,7 @@ export async function reportUsageToTeam(
onDelivered(() => import('./votes.js').then(({ confirmVotesSynced }) =>
confirmVotesSynced(path.join(votesDir!, `${username}.yaml`), votesStaged.syncedDeltas!)));
}
return filesToPush.length > 0 ? { files: [...filesToPush], message: commitMsg } : null;
return { files: [...filesToPush], message: commitMsg };
}, { pushIfUnchanged: true });
const pushed = isPublished(result);
if (!pushed) {
Expand Down
9 changes: 3 additions & 6 deletions src/utils/branch-worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -775,12 +775,9 @@ async function updateImpl(
if (!delivered) delivered = fn;
},
});
let result: PublishResult;
if (!change || change.files.length === 0) {
result = { status: 'already-present' };
} else {
result = await commitAndPushAt(spec, wt, change.message, change.files, options);
}
const result = !change
? { status: 'already-present' } satisfies PublishResult
: await commitAndPushAt(spec, wt, change.message, change.files, options);
if (delivered && isDelivered(result)) {
try {
await delivered();
Expand Down
Loading