Skip to content

Commit c0909c6

Browse files
dcramerclaude
andcommitted
fix(sync): Use commit SHA to verify push status before closing issues
Replace gitignore-based storage checking with commit SHA verification. GitHub issues are now only closed when the task's associated commit exists on origin/HEAD, ensuring issues stay open until work is actually pushed. Auto-archive also respects this - tasks with unpushed commits won't be archived. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 15216a1 commit c0909c6

File tree

5 files changed

+289
-216
lines changed

5 files changed

+289
-216
lines changed

src/core/auto-archive.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ vi.mock("node:fs", async () => {
1414
};
1515
});
1616

17+
// Mock git-utils for commit checking
18+
vi.mock("./git-utils.js", () => ({
19+
isCommitOnRemote: vi.fn(() => true), // Default: commits are on remote
20+
}));
21+
1722
const createTask = (overrides: Partial<Task> = {}): Task => ({
1823
id: "test-id",
1924
parent_id: null,
@@ -375,4 +380,91 @@ describe("performAutoArchive", () => {
375380
// With default keep_recent of 50, this task won't be archived
376381
expect(result.archivedCount).toBe(0);
377382
});
383+
384+
describe("commit-based archive filtering", () => {
385+
it("does not archive tasks with unpushed commits", async () => {
386+
const { isCommitOnRemote } = await import("./git-utils.js");
387+
vi.mocked(isCommitOnRemote).mockReturnValue(false); // Commit not pushed
388+
389+
const store: TaskStore = {
390+
tasks: [
391+
createTask({
392+
id: "unpushed-task",
393+
completed: true,
394+
completed_at: "2024-01-01T00:00:00.000Z",
395+
metadata: {
396+
commit: {
397+
sha: "abc123",
398+
message: "Fix bug",
399+
},
400+
},
401+
}),
402+
],
403+
};
404+
405+
const result = performAutoArchive(store, testStoragePath, {
406+
auto: true,
407+
age_days: 90,
408+
keep_recent: 0,
409+
});
410+
411+
expect(result.archivedCount).toBe(0);
412+
expect(store.tasks).toHaveLength(1);
413+
});
414+
415+
it("archives tasks with pushed commits", async () => {
416+
const { isCommitOnRemote } = await import("./git-utils.js");
417+
vi.mocked(isCommitOnRemote).mockReturnValue(true); // Commit is pushed
418+
419+
const store: TaskStore = {
420+
tasks: [
421+
createTask({
422+
id: "pushed-task",
423+
completed: true,
424+
completed_at: "2024-01-01T00:00:00.000Z",
425+
metadata: {
426+
commit: {
427+
sha: "abc123",
428+
message: "Fix bug",
429+
},
430+
},
431+
}),
432+
],
433+
};
434+
435+
const result = performAutoArchive(store, testStoragePath, {
436+
auto: true,
437+
age_days: 90,
438+
keep_recent: 0,
439+
});
440+
441+
expect(result.archivedCount).toBe(1);
442+
expect(store.tasks).toHaveLength(0);
443+
});
444+
445+
it("archives tasks without commit metadata (uses local status)", async () => {
446+
const { isCommitOnRemote } = await import("./git-utils.js");
447+
vi.mocked(isCommitOnRemote).mockReturnValue(true); // No commit = assume pushed
448+
449+
const store: TaskStore = {
450+
tasks: [
451+
createTask({
452+
id: "no-commit-task",
453+
completed: true,
454+
completed_at: "2024-01-01T00:00:00.000Z",
455+
// No commit metadata
456+
}),
457+
],
458+
};
459+
460+
const result = performAutoArchive(store, testStoragePath, {
461+
auto: true,
462+
age_days: 90,
463+
keep_recent: 0,
464+
});
465+
466+
expect(result.archivedCount).toBe(1);
467+
expect(store.tasks).toHaveLength(0);
468+
});
469+
});
378470
});

src/core/auto-archive.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as fs from "node:fs";
22
import * as path from "node:path";
3-
import { ArchivedTask, TaskStore } from "../types.js";
3+
import { ArchivedTask, Task, TaskStore } from "../types.js";
44
import { ArchiveConfig } from "./config.js";
55
import {
66
findAutoArchivableTasks,
@@ -10,6 +10,7 @@ import {
1010
} from "./archive-compactor.js";
1111
import { ArchiveStorage } from "./storage/archive-storage.js";
1212
import { cleanupTaskReferences } from "./task-relationships.js";
13+
import { isCommitOnRemote } from "./git-utils.js";
1314

1415
/**
1516
* Default auto-archive configuration.
@@ -41,6 +42,16 @@ function toAutoArchiveConfig(config: ArchiveConfig): AutoArchiveConfig {
4142
};
4243
}
4344

45+
/**
46+
* Check if a task's commit has been pushed to origin.
47+
* Only tasks with pushed commits (or no commit SHA) can be auto-archived.
48+
*/
49+
function isTaskPushed(task: Task): boolean {
50+
const sha = task.metadata?.commit?.sha;
51+
// No commit SHA means we can't verify - allow archiving
52+
return !sha || isCommitOnRemote(sha);
53+
}
54+
4455
/**
4556
* Log an auto-archive event to the archive log file.
4657
*/
@@ -100,7 +111,11 @@ export function performAutoArchive(
100111
// Find eligible tasks (root-level only)
101112
const eligibleTasks = findAutoArchivableTasks(store.tasks, autoArchiveConfig);
102113

103-
if (eligibleTasks.length === 0) {
114+
// Filter to only tasks whose commits have been pushed to origin
115+
// This ensures we don't archive work that hasn't been published yet
116+
const pushedTasks = eligibleTasks.filter(isTaskPushed);
117+
118+
if (pushedTasks.length === 0) {
104119
return { archivedCount: 0, archivedIds: [] };
105120
}
106121

@@ -111,7 +126,7 @@ export function performAutoArchive(
111126
const tasksToArchive: ArchivedTask[] = [];
112127

113128
// Process each eligible task
114-
for (const rootTask of eligibleTasks) {
129+
for (const rootTask of pushedTasks) {
115130
const collected = collectArchivableTasks(rootTask.id, store.tasks);
116131
if (!collected) continue;
117132

src/core/git-utils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { execSync } from "node:child_process";
2+
3+
/**
4+
* Check if a commit SHA exists on origin/HEAD (has been pushed).
5+
* Returns true if the commit is an ancestor of origin/HEAD.
6+
*/
7+
export function isCommitOnRemote(sha: string): boolean {
8+
try {
9+
// git merge-base --is-ancestor returns 0 if sha is ancestor of origin/HEAD
10+
execSync(`git merge-base --is-ancestor ${sha} origin/HEAD`, {
11+
stdio: ["pipe", "pipe", "pipe"],
12+
});
13+
return true;
14+
} catch {
15+
// Not an ancestor, or git command failed
16+
return false;
17+
}
18+
}

0 commit comments

Comments
 (0)