alignfirst 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/cli.js +3 -2
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/plans.js +8 -8
- package/dist/commands/sync.d.ts +1 -1
- package/dist/commands/sync.js +14 -13
- package/dist/context.d.ts +5 -3
- package/dist/conventions.js +8 -9
- package/dist/git.d.ts +4 -1
- package/dist/git.js +42 -16
- package/dist/plans/conflicts.d.ts +2 -0
- package/dist/plans/conflicts.js +233 -0
- package/dist/plans/layout.js +1 -1
- package/dist/plans/link.js +1 -1
- package/dist/plans/rebase.d.ts +2 -0
- package/dist/plans/rebase.js +32 -4
- package/package.json +2 -2
- package/templates/guide/code-review/reviewer-common.md +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# alignfirst
|
|
2
2
|
|
|
3
|
-
The AlignFirst CLI provides collaborative software-development workflows, work files, and documentation discovery. Work files are organized by ticket and kept either git-ignored in the project or synchronized through a
|
|
3
|
+
The AlignFirst CLI provides collaborative software-development workflows, work files, and documentation discovery. Work files are organized by ticket and kept either git-ignored in the project or synchronized through a work-files repository.
|
|
4
4
|
|
|
5
5
|
## Agent skills
|
|
6
6
|
|
|
@@ -45,7 +45,7 @@ These examples use the `/` form. Replace it with `$` in Codex.
|
|
|
45
45
|
|
|
46
46
|
To implement a plan, start a fresh agent context and ask it to execute the plan file.
|
|
47
47
|
|
|
48
|
-
AlignFirst stores specifications, plans
|
|
48
|
+
AlignFirst stores the work files of a ticket, such as specifications, plans and summaries, in `.plans/<ticket-id>/`. It normally derives the ticket ID from the request or branch and asks when none is available. Files use a cycle letter and sequence number, such as `A1-spec.md` and `A2-plan.md`.
|
|
49
49
|
|
|
50
50
|
## Global CLI
|
|
51
51
|
|
|
@@ -81,8 +81,8 @@ The guide installs the selected components and configures the repository. You ca
|
|
|
81
81
|
|
|
82
82
|
- `guide` — Print an AlignFirst protocol.
|
|
83
83
|
- `ticket` — Resolve a ticket directory, load its history, or get its next file.
|
|
84
|
-
- `sync` — Synchronize
|
|
85
|
-
- `plans` —
|
|
84
|
+
- `sync` — Synchronize the work files with the work-files repository.
|
|
85
|
+
- `plans` — Link `.plans` to the work-files repository, check the link, archive tickets.
|
|
86
86
|
- `docmap` — Browse project documentation.
|
|
87
87
|
- `conventions` — Print the effective project conventions.
|
|
88
88
|
- `context` — Print the conventions, the documentation map when `docs/` exists, and the protocol aliases.
|
package/dist/cli.js
CHANGED
|
@@ -39,7 +39,8 @@ export async function main(options) {
|
|
|
39
39
|
ctx.projectConfig = resolveProjectConfig(ctx.cwd);
|
|
40
40
|
checkCliRange(ctx.projectConfig?.config, ctx.version, [command, ...args]);
|
|
41
41
|
}
|
|
42
|
-
|
|
42
|
+
const code = await dispatch(ctx, command, args);
|
|
43
|
+
return code;
|
|
43
44
|
}
|
|
44
45
|
catch (error) {
|
|
45
46
|
if (!(error instanceof CliError))
|
|
@@ -55,7 +56,7 @@ function readPackageVersion() {
|
|
|
55
56
|
return pkg.version;
|
|
56
57
|
}
|
|
57
58
|
function renderHelp(ctx) {
|
|
58
|
-
return `alignfirst — protocols,
|
|
59
|
+
return `alignfirst — protocols, work files and docs in one command.
|
|
59
60
|
|
|
60
61
|
Usage:
|
|
61
62
|
${ctx.form} guide [<protocol>]
|
package/dist/commands/doctor.js
CHANGED
|
@@ -22,7 +22,7 @@ export function runDoctor(ctx, args) {
|
|
|
22
22
|
return inspectConfig(ctx, resolved);
|
|
23
23
|
});
|
|
24
24
|
writeSection(ctx, "Git", () => inspectGit(ctx, resolved));
|
|
25
|
-
writeSection(ctx, "
|
|
25
|
+
writeSection(ctx, "Work files", () => inspectPlans(ctx));
|
|
26
26
|
writeSection(ctx, "Docmap", () => inspectDocmap(ctx));
|
|
27
27
|
writeSection(ctx, "Skills", () => inspectSkills(ctx));
|
|
28
28
|
return 0;
|
package/dist/commands/plans.js
CHANGED
|
@@ -51,10 +51,10 @@ function runSetup(ctx, args) {
|
|
|
51
51
|
}
|
|
52
52
|
function createPlansDirectory(cloneDir, folder) {
|
|
53
53
|
if (folder.length === 0 || folder === "." || folder === ".." || /[\\/]/u.test(folder)) {
|
|
54
|
-
throw new CliError(`
|
|
54
|
+
throw new CliError(`Project folder "${folder}" must be a single path segment.`);
|
|
55
55
|
}
|
|
56
56
|
if (RESERVED_PLANS_FOLDERS.has(folder.toLowerCase())) {
|
|
57
|
-
throw new CliError(`
|
|
57
|
+
throw new CliError(`Project folder "${folder}" is reserved.`);
|
|
58
58
|
}
|
|
59
59
|
const cloneRoot = realpathSync(cloneDir);
|
|
60
60
|
const projectDir = join(cloneRoot, folder);
|
|
@@ -64,7 +64,7 @@ function createPlansDirectory(cloneDir, folder) {
|
|
|
64
64
|
relativeTarget === ".." ||
|
|
65
65
|
relativeTarget.startsWith(`..${sep}`) ||
|
|
66
66
|
isAbsolute(relativeTarget)) {
|
|
67
|
-
throw new CliError(`
|
|
67
|
+
throw new CliError(`Project folder "${folder}" must resolve inside ${cloneRoot}.`);
|
|
68
68
|
}
|
|
69
69
|
return projectDir;
|
|
70
70
|
}
|
|
@@ -94,11 +94,11 @@ function parseSetupArgs(ctx, args, usage) {
|
|
|
94
94
|
}
|
|
95
95
|
function checkClone(ctx, cloneDir) {
|
|
96
96
|
if (!existsSync(cloneDir))
|
|
97
|
-
throw new CliError(`${cloneDir} does not exist. Clone the
|
|
97
|
+
throw new CliError(`${cloneDir} does not exist. Clone the work-files repository there first (see the instruction file).`);
|
|
98
98
|
if (!existsSync(join(cloneDir, ".git")))
|
|
99
|
-
throw new CliError(`${cloneDir} is not a git repository. Point ${ctx.form} plans setup at a clone of the
|
|
99
|
+
throw new CliError(`${cloneDir} is not a git repository. Point ${ctx.form} plans setup at a clone of the work-files repository.`);
|
|
100
100
|
if (realpathSync(cloneDir) === realpathSync(ctx.cwd))
|
|
101
|
-
throw new CliError(`${cloneDir} is the product repository itself. Point ${ctx.form} plans setup at a clone of the
|
|
101
|
+
throw new CliError(`${cloneDir} is the product repository itself. Point ${ctx.form} plans setup at a clone of the work-files repository.`);
|
|
102
102
|
}
|
|
103
103
|
function runCheck(ctx, args) {
|
|
104
104
|
const usage = `Usage: ${ctx.form} plans check\n`;
|
|
@@ -111,9 +111,9 @@ function runCheck(ctx, args) {
|
|
|
111
111
|
throw new CliError(renderStoppedRebase(stopped, ctx.form));
|
|
112
112
|
}
|
|
113
113
|
if (mode.kind === "shared")
|
|
114
|
-
ctx.stdout.write(".plans is linked to the
|
|
114
|
+
ctx.stdout.write(".plans is linked to the work-files repository.\n");
|
|
115
115
|
else
|
|
116
|
-
ctx.stdout.write(".plans is a local directory (local
|
|
116
|
+
ctx.stdout.write(".plans is a local directory (local mode): synchronization is disabled.\n");
|
|
117
117
|
return 0;
|
|
118
118
|
}
|
|
119
119
|
function runAutoArchive(ctx, args) {
|
package/dist/commands/sync.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { CommandContext } from "../context.js";
|
|
2
|
-
export declare function runSync(ctx: CommandContext, args: string[]): number
|
|
2
|
+
export declare function runSync(ctx: CommandContext, args: string[]): Promise<number>;
|
package/dist/commands/sync.js
CHANGED
|
@@ -5,8 +5,8 @@ import { git, gitOutput, gitSucceeds } from "../git.js";
|
|
|
5
5
|
import { parseCommandArgs } from "../parse-args.js";
|
|
6
6
|
import { archiveThresholdDays, autoArchive } from "../plans/archive.js";
|
|
7
7
|
import { resolvePlansMode } from "../plans/mode.js";
|
|
8
|
-
import { findStoppedRebase, renderStoppedRebase } from "../plans/rebase.js";
|
|
9
|
-
export function runSync(ctx, args) {
|
|
8
|
+
import { findStoppedRebase, renderStoppedRebase, resolveStoppedRebase } from "../plans/rebase.js";
|
|
9
|
+
export async function runSync(ctx, args) {
|
|
10
10
|
const usage = `Usage: ${ctx.form} sync [--auto-archive | --no-auto-archive]\n`;
|
|
11
11
|
const options = parseSyncArgs(ctx, args, usage);
|
|
12
12
|
if (options === undefined)
|
|
@@ -22,39 +22,40 @@ export function runSync(ctx, args) {
|
|
|
22
22
|
if (mode.kind === "local") {
|
|
23
23
|
if (thresholdDays !== undefined)
|
|
24
24
|
autoArchive(plansDir, thresholdDays, ctx.stdout);
|
|
25
|
-
ctx.stdout.write("(local
|
|
25
|
+
ctx.stdout.write("(local mode, nothing to sync)\n");
|
|
26
26
|
return 0;
|
|
27
27
|
}
|
|
28
28
|
const repoDir = mode.repoToplevel;
|
|
29
29
|
assertNoStoppedRebase(repoDir, ctx.form);
|
|
30
|
-
git(repoDir, "add", "-A");
|
|
30
|
+
await git(ctx, repoDir, "add", "-A");
|
|
31
31
|
if (hasStagedChanges(repoDir))
|
|
32
|
-
git(repoDir, "commit", "--quiet", "-m", "sync");
|
|
32
|
+
await git(ctx, repoDir, "commit", "--quiet", "-m", "sync");
|
|
33
33
|
if (hasUpstream(repoDir)) {
|
|
34
34
|
try {
|
|
35
|
-
git(repoDir, "pull", "--rebase");
|
|
35
|
+
await git(ctx, repoDir, "pull", "--rebase");
|
|
36
36
|
}
|
|
37
37
|
catch {
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
if (findStoppedRebase(repoDir) === undefined)
|
|
39
|
+
throw new CliError("git pull failed. See the git output above.");
|
|
40
|
+
await resolveStoppedRebase(ctx, repoDir);
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
if (thresholdDays !== undefined && autoArchive(plansDir, thresholdDays, ctx.stdout)) {
|
|
43
|
-
git(repoDir, "add", "-A");
|
|
44
|
+
await git(ctx, repoDir, "add", "-A");
|
|
44
45
|
if (hasStagedChanges(repoDir))
|
|
45
|
-
git(repoDir, "commit", "--quiet", "-m", "sync");
|
|
46
|
+
await git(ctx, repoDir, "commit", "--quiet", "-m", "sync");
|
|
46
47
|
}
|
|
47
48
|
if (hasCommitsToSend(repoDir)) {
|
|
48
49
|
try {
|
|
49
|
-
git(repoDir, "push", "--quiet", "-u", "origin", "HEAD");
|
|
50
|
+
await git(ctx, repoDir, "push", "--quiet", "-u", "origin", "HEAD");
|
|
50
51
|
}
|
|
51
52
|
catch {
|
|
52
53
|
throw new CliError(`git push failed. See the git output above. Another synchronization may have landed first: run ${ctx.form} sync again.`);
|
|
53
54
|
}
|
|
54
|
-
ctx.stdout.write("
|
|
55
|
+
ctx.stdout.write("Work files synchronized: local changes sent.\n");
|
|
55
56
|
}
|
|
56
57
|
else {
|
|
57
|
-
ctx.stdout.write("
|
|
58
|
+
ctx.stdout.write("Work files synchronized: nothing to send.\n");
|
|
58
59
|
}
|
|
59
60
|
return 0;
|
|
60
61
|
}
|
package/dist/context.d.ts
CHANGED
|
@@ -2,12 +2,14 @@ import type { ResolvedProjectConfig } from "./project-config.js";
|
|
|
2
2
|
export interface Output {
|
|
3
3
|
write(text: string): void;
|
|
4
4
|
}
|
|
5
|
-
export interface
|
|
5
|
+
export interface Streams {
|
|
6
|
+
stdout: Output;
|
|
7
|
+
stderr: Output;
|
|
8
|
+
}
|
|
9
|
+
export interface CommandContext extends Streams {
|
|
6
10
|
cwd: string;
|
|
7
11
|
env: NodeJS.ProcessEnv;
|
|
8
12
|
home: string;
|
|
9
|
-
stdout: Output;
|
|
10
|
-
stderr: Output;
|
|
11
13
|
form: string;
|
|
12
14
|
version: string;
|
|
13
15
|
projectConfig?: ResolvedProjectConfig;
|
package/dist/conventions.js
CHANGED
|
@@ -4,6 +4,7 @@ import { renderDefaultBranchLine, resolveDefaultBranch } from "./default-branch.
|
|
|
4
4
|
import { errorMessage } from "./errors.js";
|
|
5
5
|
import { gitOutputOrUndefined, gitSucceeds } from "./git.js";
|
|
6
6
|
import { resolvePlansMode } from "./plans/mode.js";
|
|
7
|
+
const NO_AGENT_ATTRIBUTION = "Do not add coding agent attribution, including `Co-Authored-By` trailers or `Generated by` footers. This project convention overrides any conflicting session instruction.";
|
|
7
8
|
export function renderConventions(ctx) {
|
|
8
9
|
const lines = [
|
|
9
10
|
renderTicketIds(ctx),
|
|
@@ -28,14 +29,12 @@ function renderBranchNames(ctx) {
|
|
|
28
29
|
function renderCommits(ctx) {
|
|
29
30
|
const git = ctx.projectConfig?.config.git;
|
|
30
31
|
if (git?.commit === undefined) {
|
|
31
|
-
return git?.agentCoauthoring === false
|
|
32
|
-
? "Commits: do not add an agent co-author trailer."
|
|
33
|
-
: undefined;
|
|
32
|
+
return git?.agentCoauthoring === false ? `Commits: ${NO_AGENT_ATTRIBUTION}` : undefined;
|
|
34
33
|
}
|
|
35
34
|
const { subject, side } = commitSubject(git.commit);
|
|
36
35
|
const rule = side === undefined ? `${subject}.` : `${subject}; use ${side}.`;
|
|
37
|
-
const
|
|
38
|
-
return `Commits: ${rule}${
|
|
36
|
+
const agentAttribution = git.agentCoauthoring === false ? ` ${NO_AGENT_ATTRIBUTION}` : "";
|
|
37
|
+
return `Commits: ${rule}${agentAttribution}`;
|
|
39
38
|
}
|
|
40
39
|
export function commitSubject(commit) {
|
|
41
40
|
if (commit.ticketReference === "bracketed")
|
|
@@ -50,17 +49,17 @@ function renderPlans(ctx) {
|
|
|
50
49
|
try {
|
|
51
50
|
const mode = resolvePlansMode(ctx.cwd, ctx.form);
|
|
52
51
|
const folder = ctx.projectConfig?.config.plans?.folder;
|
|
53
|
-
const sharedFolder = folder === undefined ? "" : ` (
|
|
52
|
+
const sharedFolder = folder === undefined ? "" : ` (folder \`${folder}\` in the work-files repository)`;
|
|
54
53
|
const base = mode.kind === "shared"
|
|
55
|
-
? `
|
|
56
|
-
: "
|
|
54
|
+
? `Work files: use \`.plans\`${sharedFolder}; run \`${ctx.form} sync\` after changes.`
|
|
55
|
+
: "Work files: use `.plans`.";
|
|
57
56
|
const archival = ctx.projectConfig?.config.plans?.autoArchive === true
|
|
58
57
|
? " Automatic archival is enabled."
|
|
59
58
|
: "";
|
|
60
59
|
return `${base}${archival}`;
|
|
61
60
|
}
|
|
62
61
|
catch (error) {
|
|
63
|
-
return `
|
|
62
|
+
return `Work files: ${errorMessage(error).split("\n", 1)[0]}`;
|
|
64
63
|
}
|
|
65
64
|
}
|
|
66
65
|
function plansEntryExists(cwd) {
|
package/dist/git.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
import type { Streams } from "./context.js";
|
|
2
|
+
export declare function git(streams: Streams, dir: string, ...args: string[]): Promise<void>;
|
|
2
3
|
export declare function assertMainWorktreeRoot(cwd: string): void;
|
|
3
4
|
export declare function gitOutput(dir: string, ...args: string[]): string;
|
|
5
|
+
export declare function gitOutputRaw(dir: string, ...args: string[]): string;
|
|
6
|
+
export declare function gitBuffer(dir: string, ...args: string[]): Buffer;
|
|
4
7
|
export declare function gitOutputOrUndefined(dir: string, ...args: string[]): string | undefined;
|
|
5
8
|
export declare function gitSucceeds(dir: string, ...args: string[]): boolean;
|
package/dist/git.js
CHANGED
|
@@ -1,17 +1,33 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
1
|
+
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { CliError } from "./cli-error.js";
|
|
5
|
-
export function git(dir, ...args) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
export function git(streams, dir, ...args) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const child = spawn("git", ["-C", dir, ...args], {
|
|
8
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
9
|
+
});
|
|
10
|
+
child.stdout.setEncoding("utf8");
|
|
11
|
+
child.stderr.setEncoding("utf8");
|
|
12
|
+
child.stdout.on("data", (text) => streams.stdout.write(text));
|
|
13
|
+
child.stderr.on("data", (text) => streams.stderr.write(text));
|
|
14
|
+
child.once("error", (error) => reject(gitFailure(args, error.message)));
|
|
15
|
+
child.once("close", (code) => (code === 0 ? resolve() : reject(gitFailure(args))));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
function gitFailure(args, detail) {
|
|
19
|
+
const output = detail?.trim();
|
|
20
|
+
const subcommand = gitSubcommand(args);
|
|
21
|
+
const label = subcommand === undefined ? "git command" : `git ${subcommand}`;
|
|
22
|
+
if (output === undefined || output === "")
|
|
23
|
+
return new CliError(`${label} failed. See the git output above.`);
|
|
24
|
+
return new CliError(`${label} failed:\n${output}`);
|
|
12
25
|
}
|
|
13
|
-
function
|
|
14
|
-
|
|
26
|
+
function gitSubcommand(args) {
|
|
27
|
+
let index = 0;
|
|
28
|
+
while (args[index] === "-c")
|
|
29
|
+
index += 2;
|
|
30
|
+
return args[index];
|
|
15
31
|
}
|
|
16
32
|
export function assertMainWorktreeRoot(cwd) {
|
|
17
33
|
const toplevel = gitOutput(cwd, "rev-parse", "--show-toplevel");
|
|
@@ -23,12 +39,22 @@ export function assertMainWorktreeRoot(cwd) {
|
|
|
23
39
|
throw new CliError("Run this command from the main worktree. Linked worktrees reach .plans through it.");
|
|
24
40
|
}
|
|
25
41
|
export function gitOutput(dir, ...args) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
42
|
+
return gitOutputRaw(dir, ...args).trim();
|
|
43
|
+
}
|
|
44
|
+
export function gitOutputRaw(dir, ...args) {
|
|
45
|
+
const output = gitBuffer(dir, ...args);
|
|
46
|
+
const text = output.toString("utf8");
|
|
47
|
+
if (!Buffer.from(text).equals(output))
|
|
48
|
+
throw new CliError("Cannot read non-UTF-8 Git output safely. Resolve the rebase manually.");
|
|
49
|
+
return text;
|
|
50
|
+
}
|
|
51
|
+
export function gitBuffer(dir, ...args) {
|
|
52
|
+
const result = spawnSync("git", ["-C", dir, ...args]);
|
|
53
|
+
if (result.error !== undefined)
|
|
54
|
+
throw gitFailure(args, result.error.message);
|
|
55
|
+
if (result.status !== 0)
|
|
56
|
+
throw gitFailure(args, result.stderr.toString("utf8"));
|
|
57
|
+
return result.stdout;
|
|
32
58
|
}
|
|
33
59
|
export function gitOutputOrUndefined(dir, ...args) {
|
|
34
60
|
try {
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { CliError } from "../cli-error.js";
|
|
4
|
+
import { gitBuffer, gitOutput, gitOutputRaw } from "../git.js";
|
|
5
|
+
import { nextFilePosition } from "./ticket.js";
|
|
6
|
+
export function resolveConflictedPaths(streams, repoDir) {
|
|
7
|
+
const conflicts = readConflicts(repoDir);
|
|
8
|
+
const remote = readTree(repoDir, "HEAD");
|
|
9
|
+
const local = readTree(repoDir, "REBASE_HEAD");
|
|
10
|
+
const resolution = {
|
|
11
|
+
files: new Map(),
|
|
12
|
+
renames: new Map(),
|
|
13
|
+
reserved: new Set([...remote.keys(), ...local.keys()]),
|
|
14
|
+
};
|
|
15
|
+
for (const [path, stages] of conflicts) {
|
|
16
|
+
const ours = conflictBlob(path, stages.has(2), remote);
|
|
17
|
+
const theirs = conflictBlob(path, stages.has(3), local);
|
|
18
|
+
if (!ours && !theirs && (remote.has(path) || local.has(path)))
|
|
19
|
+
throw new CliError(`Cannot recover both versions of ${path}. Resolve the rebase manually.`);
|
|
20
|
+
if (ours)
|
|
21
|
+
resolution.files.set(path, { blob: ours });
|
|
22
|
+
if (!theirs)
|
|
23
|
+
continue;
|
|
24
|
+
if (ours && (ours.id !== theirs.id || ours.mode !== theirs.mode)) {
|
|
25
|
+
preserveLocalCopy(repoDir, path, theirs, resolution);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
resolution.files.set(path, { blob: theirs, localPath: path });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
preserveCompanionSummaries(repoDir, remote, local, resolution);
|
|
32
|
+
preserveLocalReferences(repoDir, remote, local, resolution);
|
|
33
|
+
assertNoLaterEdits(repoDir, resolution.renames);
|
|
34
|
+
applyResolution(repoDir, conflicts, resolution);
|
|
35
|
+
for (const path of conflicts.keys()) {
|
|
36
|
+
const renamed = resolution.renames.get(path);
|
|
37
|
+
streams.stdout.write(renamed === undefined
|
|
38
|
+
? `Resolved ${path}: preserved committed contents at the surviving paths.\n`
|
|
39
|
+
: `Resolved ${path}: kept the published version; saved the local version as ${renamed}.\n`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function readConflicts(repoDir) {
|
|
43
|
+
const result = new Map();
|
|
44
|
+
for (const entry of gitOutputRaw(repoDir, "ls-files", "-u", "-z").split("\0")) {
|
|
45
|
+
if (entry === "")
|
|
46
|
+
continue;
|
|
47
|
+
const tab = entry.indexOf("\t");
|
|
48
|
+
const header = entry.slice(0, tab).split(" ");
|
|
49
|
+
const stage = Number(header[2]);
|
|
50
|
+
if (tab === -1 || ![1, 2, 3].includes(stage))
|
|
51
|
+
throw new CliError("Could not read the git index of the work-files repository.");
|
|
52
|
+
const path = entry.slice(tab + 1);
|
|
53
|
+
const stages = result.get(path) ?? new Set();
|
|
54
|
+
stages.add(stage);
|
|
55
|
+
result.set(path, stages);
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
function readTree(repoDir, commit) {
|
|
60
|
+
const result = new Map();
|
|
61
|
+
for (const entry of gitOutputRaw(repoDir, "ls-tree", "-r", "-z", commit).split("\0")) {
|
|
62
|
+
if (entry === "")
|
|
63
|
+
continue;
|
|
64
|
+
const tab = entry.indexOf("\t");
|
|
65
|
+
const [mode, , id] = entry.slice(0, tab).split(" ");
|
|
66
|
+
result.set(entry.slice(tab + 1), { id, mode });
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
function conflictBlob(path, present, tree) {
|
|
71
|
+
if (!present)
|
|
72
|
+
return;
|
|
73
|
+
const blob = tree.get(path);
|
|
74
|
+
if (!blob || !["100644", "100755"].includes(blob.mode))
|
|
75
|
+
throw new CliError(`Cannot automatically preserve conflict at ${path}. Resolve the rebase manually.`);
|
|
76
|
+
return blob;
|
|
77
|
+
}
|
|
78
|
+
function preserveLocalCopy(repoDir, path, blob, resolution) {
|
|
79
|
+
const target = freeLocalPath(repoDir, path, resolution.reserved);
|
|
80
|
+
resolution.renames.set(path, target);
|
|
81
|
+
resolution.reserved.add(target);
|
|
82
|
+
resolution.files.set(target, { blob, localPath: path });
|
|
83
|
+
}
|
|
84
|
+
function freeLocalPath(repoDir, path, reserved) {
|
|
85
|
+
const dir = dirname(path);
|
|
86
|
+
const names = [
|
|
87
|
+
...readdirSync(join(repoDir, dir)),
|
|
88
|
+
...[...reserved].filter((entry) => dirname(entry) === dir).map((entry) => basename(entry)),
|
|
89
|
+
];
|
|
90
|
+
const name = basename(path);
|
|
91
|
+
const numbered = /^[A-Z]\d+-(.+)$/s.exec(name);
|
|
92
|
+
const { cycleLetter, fileNumber } = nextFilePosition(names, false);
|
|
93
|
+
const extension = name.endsWith(".summary.md") ? ".summary.md" : extname(name);
|
|
94
|
+
const stem = name.slice(0, name.length - extension.length);
|
|
95
|
+
for (let offset = 0;; ++offset) {
|
|
96
|
+
const candidate = join(dir, numbered
|
|
97
|
+
? `${cycleLetter}${fileNumber + offset}-${numbered[1]}`
|
|
98
|
+
: `${stem}-local-${offset + 2}${extension}`);
|
|
99
|
+
if (reserved.has(candidate) || lstatSync(join(repoDir, candidate), { throwIfNoEntry: false }))
|
|
100
|
+
continue;
|
|
101
|
+
const companion = `${candidate.slice(0, -3)}.summary.md`;
|
|
102
|
+
if (name.endsWith(".md") &&
|
|
103
|
+
reserved.has(`${path.slice(0, -3)}.summary.md`) &&
|
|
104
|
+
(reserved.has(companion) || lstatSync(join(repoDir, companion), { throwIfNoEntry: false })))
|
|
105
|
+
continue;
|
|
106
|
+
return candidate;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function preserveCompanionSummaries(repoDir, remote, local, resolution) {
|
|
110
|
+
for (const [source, target] of resolution.renames) {
|
|
111
|
+
if (!source.endsWith(".md"))
|
|
112
|
+
continue;
|
|
113
|
+
const companion = `${source.slice(0, -3)}.summary.md`;
|
|
114
|
+
const blob = local.get(companion);
|
|
115
|
+
if (!blob)
|
|
116
|
+
continue;
|
|
117
|
+
const companionTarget = `${target.slice(0, -3)}.summary.md`;
|
|
118
|
+
if (resolution.reserved.has(companionTarget) ||
|
|
119
|
+
lstatSync(join(repoDir, companionTarget), { throwIfNoEntry: false }))
|
|
120
|
+
throw new CliError(`Cannot preserve related summary ${companion}: ${companionTarget} exists.`);
|
|
121
|
+
conflictBlob(companion, true, local);
|
|
122
|
+
const previousTarget = resolution.renames.get(companion);
|
|
123
|
+
if (previousTarget !== undefined)
|
|
124
|
+
resolution.files.delete(previousTarget);
|
|
125
|
+
resolution.renames.set(companion, companionTarget);
|
|
126
|
+
resolution.reserved.add(companionTarget);
|
|
127
|
+
resolution.files.set(companionTarget, { blob, localPath: companion });
|
|
128
|
+
const published = conflictBlob(companion, remote.has(companion), remote);
|
|
129
|
+
if (published)
|
|
130
|
+
resolution.files.set(companion, { blob: published });
|
|
131
|
+
else
|
|
132
|
+
resolution.files.delete(companion);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function preserveLocalReferences(repoDir, remote, local, resolution) {
|
|
136
|
+
if (resolution.renames.size === 0)
|
|
137
|
+
return;
|
|
138
|
+
const published = readTree(repoDir, rebaseCommit(repoDir, "onto"));
|
|
139
|
+
const added = new Set(gitOutputRaw(repoDir, "diff", "--cached", "--name-only", "--diff-filter=A", "-z")
|
|
140
|
+
.split("\0")
|
|
141
|
+
.filter((path) => path !== ""));
|
|
142
|
+
for (const [path, blob] of local) {
|
|
143
|
+
if (!path.endsWith(".md") ||
|
|
144
|
+
(!added.has(path) && (!remote.has(path) || published.has(path))) ||
|
|
145
|
+
resolution.renames.has(path))
|
|
146
|
+
continue;
|
|
147
|
+
if (blob.mode === "100644" || blob.mode === "100755")
|
|
148
|
+
resolution.files.set(path, { blob, localPath: path });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function rebaseCommit(repoDir, name) {
|
|
152
|
+
const state = gitOutput(repoDir, "rev-parse", "--git-path", `rebase-merge/${name}`);
|
|
153
|
+
const path = resolve(repoDir, state);
|
|
154
|
+
if (!lstatSync(path, { throwIfNoEntry: false }))
|
|
155
|
+
throw new CliError("Cannot read the original rebase commits. Resolve the rebase manually.");
|
|
156
|
+
return readFileSync(path, "utf8").trim();
|
|
157
|
+
}
|
|
158
|
+
function assertNoLaterEdits(repoDir, renames) {
|
|
159
|
+
if (renames.size === 0)
|
|
160
|
+
return;
|
|
161
|
+
const originalHead = rebaseCommit(repoDir, "orig-head");
|
|
162
|
+
const commits = gitOutput(repoDir, "rev-list", `REBASE_HEAD..${originalHead}`);
|
|
163
|
+
for (const commit of commits.split("\n").filter((value) => value !== "")) {
|
|
164
|
+
const tree = readTree(repoDir, commit);
|
|
165
|
+
const paths = gitOutputRaw(repoDir, "diff-tree", "--no-commit-id", "--name-only", "-r", "-z", commit);
|
|
166
|
+
for (const path of paths.split("\0")) {
|
|
167
|
+
if (renames.has(path))
|
|
168
|
+
throw new CliError(`Cannot automatically rename ${path}: a later local commit edits it. Resolve the rebase manually.`);
|
|
169
|
+
const blob = tree.get(path);
|
|
170
|
+
if (!path.endsWith(".md") || !blob || !["100644", "100755"].includes(blob.mode))
|
|
171
|
+
continue;
|
|
172
|
+
const content = readBlob(repoDir, blob);
|
|
173
|
+
if (!updateReferences(content, path, renames).equals(content))
|
|
174
|
+
throw new CliError(`A later local commit references a renamed document in ${path}. Resolve the rebase manually.`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function readBlob(repoDir, blob) {
|
|
179
|
+
return gitBuffer(repoDir, "cat-file", "blob", blob.id);
|
|
180
|
+
}
|
|
181
|
+
function updateReferences(original, source, renames) {
|
|
182
|
+
const replacements = new Map();
|
|
183
|
+
for (const [before, after] of renames) {
|
|
184
|
+
replacements.set(before, after);
|
|
185
|
+
const from = relative(dirname(source), before);
|
|
186
|
+
const to = relative(dirname(source), after);
|
|
187
|
+
replacements.set(from, to);
|
|
188
|
+
replacements.set(`./${from}`, `./${to}`);
|
|
189
|
+
// A work-files repository clone stores the project's .plans directory under its configured folder.
|
|
190
|
+
replacements.set(before.replace(/^[^/]+\//, ".plans/"), after.replace(/^[^/]+\//, ".plans/"));
|
|
191
|
+
}
|
|
192
|
+
if (replacements.size === 0)
|
|
193
|
+
return original;
|
|
194
|
+
const pattern = [...replacements.keys()]
|
|
195
|
+
.sort((left, right) => right.length - left.length)
|
|
196
|
+
.map((path) => path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
197
|
+
.join("|");
|
|
198
|
+
const text = original.toString("utf8");
|
|
199
|
+
if (!Buffer.from(text).equals(original))
|
|
200
|
+
return original;
|
|
201
|
+
const updated = text.replace(new RegExp(`(?<![\\w./-])(?:${pattern})(?=$|[\\s\x60'"<>()[\\]#:])`, "g"), (match) => replacements.get(match) ?? match);
|
|
202
|
+
return Buffer.from(updated);
|
|
203
|
+
}
|
|
204
|
+
function applyResolution(repoDir, conflicts, resolution) {
|
|
205
|
+
const removed = [...new Set([...conflicts.keys(), ...resolution.renames.keys()])].filter((path) => !resolution.files.has(path));
|
|
206
|
+
for (const path of [...removed, ...resolution.files.keys()])
|
|
207
|
+
assertRegularPath(repoDir, path);
|
|
208
|
+
const writes = [...resolution.files].map(([path, file]) => {
|
|
209
|
+
const original = readBlob(repoDir, file.blob);
|
|
210
|
+
const content = file.localPath !== undefined && path.endsWith(".md")
|
|
211
|
+
? updateReferences(original, file.localPath, resolution.renames)
|
|
212
|
+
: original;
|
|
213
|
+
return { path, content, mode: file.blob.mode === "100755" ? 0o755 : 0o644 };
|
|
214
|
+
});
|
|
215
|
+
for (const path of removed)
|
|
216
|
+
rmSync(join(repoDir, path), { force: true });
|
|
217
|
+
for (const { path, content, mode } of writes) {
|
|
218
|
+
const target = join(repoDir, path);
|
|
219
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
220
|
+
writeFileSync(target, content);
|
|
221
|
+
chmodSync(target, mode);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function assertRegularPath(repoDir, path) {
|
|
225
|
+
const stat = lstatSync(join(repoDir, path), { throwIfNoEntry: false });
|
|
226
|
+
if (stat && !stat.isFile())
|
|
227
|
+
throw new CliError(`Cannot automatically replace ${path}. Resolve the rebase manually.`);
|
|
228
|
+
for (let dir = dirname(path); dir !== "."; dir = dirname(dir)) {
|
|
229
|
+
const parent = lstatSync(join(repoDir, dir), { throwIfNoEntry: false });
|
|
230
|
+
if (parent && !parent.isDirectory())
|
|
231
|
+
throw new CliError(`Cannot automatically write through ${dir}. Resolve the rebase manually.`);
|
|
232
|
+
}
|
|
233
|
+
}
|
package/dist/plans/layout.js
CHANGED
|
@@ -25,7 +25,7 @@ export function assertPlansGate(cwd, form) {
|
|
|
25
25
|
return stats;
|
|
26
26
|
}
|
|
27
27
|
export function missingPlansMessage(form) {
|
|
28
|
-
return `No .plans/ directory in the current directory.\nLocal
|
|
28
|
+
return `No .plans/ directory in the current directory.\nLocal work files: mkdir .plans && echo .plans >> .gitignore\nTeam work files: ${form} plans setup <clone-dir>`;
|
|
29
29
|
}
|
|
30
30
|
export function missingPlansError(form) {
|
|
31
31
|
return new CliError(missingPlansMessage(form));
|
package/dist/plans/link.js
CHANGED
|
@@ -6,7 +6,7 @@ export function linkPlans(ctx, targetDir) {
|
|
|
6
6
|
const stats = lstatSync(plansPath, { throwIfNoEntry: false });
|
|
7
7
|
if (stats?.isSymbolicLink()) {
|
|
8
8
|
if (existsSync(plansPath) && realpathSync(plansPath) === realpathSync(targetDir)) {
|
|
9
|
-
ctx.stdout.write(".plans already links to the
|
|
9
|
+
ctx.stdout.write(".plans already links to the work-files repository.\n");
|
|
10
10
|
return;
|
|
11
11
|
}
|
|
12
12
|
rmSync(plansPath);
|
package/dist/plans/rebase.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import type { Streams } from "../context.js";
|
|
1
2
|
export interface StoppedRebase {
|
|
2
3
|
repoDir: string;
|
|
3
4
|
conflictedFiles: string[];
|
|
4
5
|
}
|
|
5
6
|
export declare function findStoppedRebase(repoDir: string): StoppedRebase | undefined;
|
|
7
|
+
export declare function resolveStoppedRebase(streams: Streams, repoDir: string): Promise<void>;
|
|
6
8
|
export declare function renderStoppedRebase(stopped: StoppedRebase, form: string): string;
|
package/dist/plans/rebase.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { CliError } from "../cli-error.js";
|
|
4
|
+
import { git, gitOutputRaw, gitOutputOrUndefined, gitSucceeds } from "../git.js";
|
|
5
|
+
import { resolveConflictedPaths } from "./conflicts.js";
|
|
6
|
+
const MAX_REBASE_STEPS = 100;
|
|
4
7
|
export function findStoppedRebase(repoDir) {
|
|
5
8
|
const inProgress = ["rebase-merge", "rebase-apply"].some((name) => {
|
|
6
9
|
const path = gitOutputOrUndefined(repoDir, "rev-parse", "--git-path", name);
|
|
@@ -8,16 +11,41 @@ export function findStoppedRebase(repoDir) {
|
|
|
8
11
|
});
|
|
9
12
|
if (!inProgress)
|
|
10
13
|
return;
|
|
11
|
-
const conflicts =
|
|
14
|
+
const conflicts = gitOutputRaw(repoDir, "diff", "-z", "--name-only", "--diff-filter=U");
|
|
12
15
|
return {
|
|
13
16
|
repoDir,
|
|
14
|
-
conflictedFiles: conflicts
|
|
17
|
+
conflictedFiles: conflicts.split("\0").filter((path) => path !== ""),
|
|
15
18
|
};
|
|
16
19
|
}
|
|
20
|
+
export async function resolveStoppedRebase(streams, repoDir) {
|
|
21
|
+
for (let step = 0; findStoppedRebase(repoDir) !== undefined; ++step) {
|
|
22
|
+
if (step >= MAX_REBASE_STEPS)
|
|
23
|
+
throw new CliError(`Could not finish the stopped rebase in ${repoDir}.`);
|
|
24
|
+
resolveConflictedPaths(streams, repoDir);
|
|
25
|
+
await git(streams, repoDir, "add", "-A");
|
|
26
|
+
await continueRebase(streams, repoDir);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function continueRebase(streams, repoDir) {
|
|
30
|
+
const args = ["-c", "core.editor=true", "rebase", "--continue"];
|
|
31
|
+
try {
|
|
32
|
+
await git(streams, repoDir, ...args);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
const stopped = findStoppedRebase(repoDir);
|
|
36
|
+
if (stopped?.conflictedFiles.length)
|
|
37
|
+
return;
|
|
38
|
+
if (stopped && gitSucceeds(repoDir, "diff", "--cached", "--quiet")) {
|
|
39
|
+
await git(streams, repoDir, "rebase", "--skip");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
17
45
|
export function renderStoppedRebase(stopped, form) {
|
|
18
46
|
const files = stopped.conflictedFiles.map((path) => ` ${path}`);
|
|
19
47
|
return [
|
|
20
|
-
`
|
|
48
|
+
`Work-files synchronization stopped on a conflict in ${stopped.repoDir}:`,
|
|
21
49
|
...files,
|
|
22
50
|
"Resolve the markers in these files, then run:",
|
|
23
51
|
` git -C ${stopped.repoDir} add -A && git -C ${stopped.repoDir} rebase --continue`,
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "alignfirst",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"license": "CC0-1.0",
|
|
5
5
|
"author": "Thomas MUR",
|
|
6
|
-
"description": "The AlignFirst CLI: protocols,
|
|
6
|
+
"description": "The AlignFirst CLI: protocols, work files and docs in one command.",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"alignfirst",
|
|
9
9
|
"cli",
|
|
@@ -5,7 +5,7 @@ You are one of several reviewers examining the same branch, each from a differen
|
|
|
5
5
|
## Scope
|
|
6
6
|
|
|
7
7
|
- Review the changes between the merge-base and HEAD (the orchestrator gives you both): `git diff <merge_base> HEAD`. The review target is the branch as committed.
|
|
8
|
-
- Fresh eyes: derive everything from the code and the diff. Do not read specs, plans, summaries, or any file in the
|
|
8
|
+
- Fresh eyes: derive everything from the code and the diff. Do not read specs, plans, summaries, or any file in the ticket directory (under `.plans/`).
|
|
9
9
|
- Read-only: never modify the working tree, the index, or HEAD.
|
|
10
10
|
|
|
11
11
|
## Method
|