alignfirst 0.2.0 → 0.3.0
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 +52 -55
- package/dist/commands/sync.js +4 -3
- package/dist/conventions.js +4 -5
- package/dist/git.d.ts +1 -0
- package/dist/git.js +9 -1
- package/dist/plans/conflicts.d.ts +2 -0
- package/dist/plans/conflicts.js +234 -0
- package/dist/plans/rebase.d.ts +2 -0
- package/dist/plans/rebase.js +31 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,56 +1,10 @@
|
|
|
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 team repository.
|
|
4
|
-
|
|
5
|
-
## Installation
|
|
6
|
-
|
|
7
|
-
Install it globally:
|
|
8
|
-
|
|
9
|
-
```sh
|
|
10
|
-
npm install -g alignfirst
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
Or run the current version without installing it:
|
|
14
|
-
|
|
15
|
-
```sh
|
|
16
|
-
npx alignfirst
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
## Setup with your agent
|
|
20
|
-
|
|
21
|
-
Temporarily install the setup-guide skill:
|
|
22
|
-
|
|
23
|
-
```sh
|
|
24
|
-
npx skills add https://github.com/paleo/alignfirst --skill alignfirst-setup-guide
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
Then ask your agent:
|
|
28
|
-
|
|
29
|
-
```text
|
|
30
|
-
Use your alignfirst-setup-guide skill. Set up AlignFirst in this project. Ask before installing anything globally.
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
The guide installs the selected components and configures the repository. Remove the setup-guide skill when setup is complete.
|
|
34
|
-
|
|
35
|
-
## Commands
|
|
36
|
-
|
|
37
|
-
- `guide` — Print an AlignFirst protocol.
|
|
38
|
-
- `ticket` — Resolve a ticket directory, load its history, or get its next file.
|
|
39
|
-
- `sync` — Synchronize shared plans.
|
|
40
|
-
- `plans` — Set up, check and archive plans.
|
|
41
|
-
- `docmap` — Browse project documentation.
|
|
42
|
-
- `conventions` — Print the effective project conventions.
|
|
43
|
-
- `context` — Print the conventions, the documentation map when `docs/` exists, and the protocol aliases.
|
|
44
|
-
- `config` — Report the effective project configuration.
|
|
45
|
-
- `doctor` — Diagnose an AlignFirst setup.
|
|
46
|
-
|
|
47
|
-
Run `alignfirst --help` for command usage or `alignfirst guide` to choose a protocol. `alignfirst guide <protocol>` prints the selected protocol followed by the ticket directory and work file rules. Add `--protocol-only` when those rules are already in context.
|
|
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 team plans repository.
|
|
48
4
|
|
|
49
5
|
## Agent skills
|
|
50
6
|
|
|
51
|
-
Nine
|
|
52
|
-
|
|
53
|
-
Install them globally:
|
|
7
|
+
Nine Agent Skill stubs expose the CLI as commands in Claude Code, Codex, GitHub Copilot, and Cursor. Install them globally:
|
|
54
8
|
|
|
55
9
|
```sh
|
|
56
10
|
npx skills add https://github.com/paleo/alignfirst --global \
|
|
@@ -65,7 +19,13 @@ Install the `alignfirst` skill when a project's `AGENTS.md` does not run `npx al
|
|
|
65
19
|
npx skills add https://github.com/paleo/alignfirst --global --skill alignfirst
|
|
66
20
|
```
|
|
67
21
|
|
|
68
|
-
|
|
22
|
+
The skills run the CLI through `npx`, so installing it is optional.
|
|
23
|
+
|
|
24
|
+
### Update the agent skills
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npx skills update --global
|
|
28
|
+
```
|
|
69
29
|
|
|
70
30
|
## Workflows
|
|
71
31
|
|
|
@@ -87,17 +47,54 @@ To implement a plan, start a fresh agent context and ask it to execute the plan
|
|
|
87
47
|
|
|
88
48
|
AlignFirst stores 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`.
|
|
89
49
|
|
|
90
|
-
##
|
|
50
|
+
## Global CLI
|
|
51
|
+
|
|
52
|
+
To type `alignfirst` instead of `npx alignfirst`, install the CLI globally:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
npm install -g alignfirst
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Update the global CLI
|
|
91
59
|
|
|
92
|
-
|
|
60
|
+
```sh
|
|
61
|
+
npm update -g alignfirst
|
|
62
|
+
```
|
|
93
63
|
|
|
94
|
-
##
|
|
64
|
+
## Set up a project (with your agent)
|
|
65
|
+
|
|
66
|
+
Temporarily install the setup-guide skill:
|
|
95
67
|
|
|
96
68
|
```sh
|
|
97
|
-
|
|
98
|
-
|
|
69
|
+
npx skills add https://github.com/paleo/alignfirst --skill alignfirst-setup-guide
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Then ask your agent:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
Use your alignfirst-setup-guide skill. What AlignFirst tooling could we add in this project?
|
|
99
76
|
```
|
|
100
77
|
|
|
78
|
+
The guide installs the selected components and configures the repository. You can remove the setup-guide skill once setup is complete.
|
|
79
|
+
|
|
80
|
+
## CLI commands
|
|
81
|
+
|
|
82
|
+
- `guide` — Print an AlignFirst protocol.
|
|
83
|
+
- `ticket` — Resolve a ticket directory, load its history, or get its next file.
|
|
84
|
+
- `sync` — Synchronize shared plans.
|
|
85
|
+
- `plans` — Set up, check and archive plans.
|
|
86
|
+
- `docmap` — Browse project documentation.
|
|
87
|
+
- `conventions` — Print the effective project conventions.
|
|
88
|
+
- `context` — Print the conventions, the documentation map when `docs/` exists, and the protocol aliases.
|
|
89
|
+
- `config` — Report the effective project configuration.
|
|
90
|
+
- `doctor` — Diagnose an AlignFirst setup.
|
|
91
|
+
|
|
92
|
+
Run `alignfirst --help` for command usage or `alignfirst guide` to choose a protocol. `alignfirst guide <protocol>` prints the selected protocol followed by the ticket directory and work file rules. Add `--protocol-only` when those rules are already in context.
|
|
93
|
+
|
|
94
|
+
### Catchup output
|
|
95
|
+
|
|
96
|
+
`alignfirst ticket [<id>] --catchup` prints the ticket's Markdown files, plans excluded.
|
|
97
|
+
|
|
101
98
|
## Upgrade from v1, v2, or v3
|
|
102
99
|
|
|
103
100
|
Install the setup-guide skill and ask your agent to run its upgrade route:
|
|
@@ -110,7 +107,7 @@ npx skills add https://github.com/paleo/alignfirst --skill alignfirst-setup-guid
|
|
|
110
107
|
Use your alignfirst-setup-guide skill. Upgrade AlignFirst in this project.
|
|
111
108
|
```
|
|
112
109
|
|
|
113
|
-
|
|
110
|
+
You can remove the setup-guide skill once the upgrade is complete.
|
|
114
111
|
|
|
115
112
|
## License
|
|
116
113
|
|
package/dist/commands/sync.js
CHANGED
|
@@ -5,7 +5,7 @@ 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";
|
|
8
|
+
import { findStoppedRebase, renderStoppedRebase, resolveStoppedRebase } from "../plans/rebase.js";
|
|
9
9
|
export 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);
|
|
@@ -35,8 +35,9 @@ export function runSync(ctx, args) {
|
|
|
35
35
|
git(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
|
+
resolveStoppedRebase(repoDir, ctx.stdout);
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
if (thresholdDays !== undefined && autoArchive(plansDir, thresholdDays, ctx.stdout)) {
|
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")
|
package/dist/git.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export declare function git(dir: string, ...args: string[]): void;
|
|
2
2
|
export declare function assertMainWorktreeRoot(cwd: string): void;
|
|
3
3
|
export declare function gitOutput(dir: string, ...args: string[]): string;
|
|
4
|
+
export declare function gitOutputRaw(dir: string, ...args: string[]): string;
|
|
4
5
|
export declare function gitOutputOrUndefined(dir: string, ...args: string[]): string | undefined;
|
|
5
6
|
export declare function gitSucceeds(dir: string, ...args: string[]): boolean;
|
package/dist/git.js
CHANGED
|
@@ -23,12 +23,20 @@ export function assertMainWorktreeRoot(cwd) {
|
|
|
23
23
|
throw new CliError("Run this command from the main worktree. Linked worktrees reach .plans through it.");
|
|
24
24
|
}
|
|
25
25
|
export function gitOutput(dir, ...args) {
|
|
26
|
+
return gitOutputRaw(dir, ...args).trim();
|
|
27
|
+
}
|
|
28
|
+
export function gitOutputRaw(dir, ...args) {
|
|
29
|
+
let output;
|
|
26
30
|
try {
|
|
27
|
-
|
|
31
|
+
output = execFileSync("git", ["-C", dir, ...args]);
|
|
28
32
|
}
|
|
29
33
|
catch {
|
|
30
34
|
throw gitFailure(args);
|
|
31
35
|
}
|
|
36
|
+
const text = output.toString("utf8");
|
|
37
|
+
if (!Buffer.from(text).equals(output))
|
|
38
|
+
throw new CliError("Cannot read non-UTF-8 Git output safely. Resolve the rebase manually.");
|
|
39
|
+
return text;
|
|
32
40
|
}
|
|
33
41
|
export function gitOutputOrUndefined(dir, ...args) {
|
|
34
42
|
try {
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
4
|
+
import { CliError } from "../cli-error.js";
|
|
5
|
+
import { gitOutput, gitOutputRaw } from "../git.js";
|
|
6
|
+
import { nextFilePosition } from "./ticket.js";
|
|
7
|
+
export function resolveConflictedPaths(repoDir, stdout) {
|
|
8
|
+
const conflicts = readConflicts(repoDir);
|
|
9
|
+
const remote = readTree(repoDir, "HEAD");
|
|
10
|
+
const local = readTree(repoDir, "REBASE_HEAD");
|
|
11
|
+
const resolution = {
|
|
12
|
+
files: new Map(),
|
|
13
|
+
renames: new Map(),
|
|
14
|
+
reserved: new Set([...remote.keys(), ...local.keys()]),
|
|
15
|
+
};
|
|
16
|
+
for (const [path, stages] of conflicts) {
|
|
17
|
+
const ours = conflictBlob(path, stages.has(2), remote);
|
|
18
|
+
const theirs = conflictBlob(path, stages.has(3), local);
|
|
19
|
+
if (!ours && !theirs && (remote.has(path) || local.has(path)))
|
|
20
|
+
throw new CliError(`Cannot recover both versions of ${path}. Resolve the rebase manually.`);
|
|
21
|
+
if (ours)
|
|
22
|
+
resolution.files.set(path, { blob: ours });
|
|
23
|
+
if (!theirs)
|
|
24
|
+
continue;
|
|
25
|
+
if (ours && (ours.id !== theirs.id || ours.mode !== theirs.mode)) {
|
|
26
|
+
preserveLocalCopy(repoDir, path, theirs, resolution);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
resolution.files.set(path, { blob: theirs, localPath: path });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
preserveCompanionSummaries(repoDir, remote, local, resolution);
|
|
33
|
+
preserveLocalReferences(repoDir, remote, local, resolution);
|
|
34
|
+
assertNoLaterEdits(repoDir, resolution.renames);
|
|
35
|
+
applyResolution(repoDir, conflicts, resolution);
|
|
36
|
+
for (const path of conflicts.keys()) {
|
|
37
|
+
const renamed = resolution.renames.get(path);
|
|
38
|
+
stdout.write(renamed === undefined
|
|
39
|
+
? `Resolved ${path}: preserved committed contents at the surviving paths.\n`
|
|
40
|
+
: `Resolved ${path}: kept the published version; saved the local version as ${renamed}.\n`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function readConflicts(repoDir) {
|
|
44
|
+
const result = new Map();
|
|
45
|
+
for (const entry of gitOutputRaw(repoDir, "ls-files", "-u", "-z").split("\0")) {
|
|
46
|
+
if (entry === "")
|
|
47
|
+
continue;
|
|
48
|
+
const tab = entry.indexOf("\t");
|
|
49
|
+
const header = entry.slice(0, tab).split(" ");
|
|
50
|
+
const stage = Number(header[2]);
|
|
51
|
+
if (tab === -1 || ![1, 2, 3].includes(stage))
|
|
52
|
+
throw new CliError("Could not read the conflicted plans index.");
|
|
53
|
+
const path = entry.slice(tab + 1);
|
|
54
|
+
const stages = result.get(path) ?? new Set();
|
|
55
|
+
stages.add(stage);
|
|
56
|
+
result.set(path, stages);
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
function readTree(repoDir, commit) {
|
|
61
|
+
const result = new Map();
|
|
62
|
+
for (const entry of gitOutputRaw(repoDir, "ls-tree", "-r", "-z", commit).split("\0")) {
|
|
63
|
+
if (entry === "")
|
|
64
|
+
continue;
|
|
65
|
+
const tab = entry.indexOf("\t");
|
|
66
|
+
const [mode, , id] = entry.slice(0, tab).split(" ");
|
|
67
|
+
result.set(entry.slice(tab + 1), { id, mode });
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
function conflictBlob(path, present, tree) {
|
|
72
|
+
if (!present)
|
|
73
|
+
return;
|
|
74
|
+
const blob = tree.get(path);
|
|
75
|
+
if (!blob || !["100644", "100755"].includes(blob.mode))
|
|
76
|
+
throw new CliError(`Cannot automatically preserve conflict at ${path}. Resolve the rebase manually.`);
|
|
77
|
+
return blob;
|
|
78
|
+
}
|
|
79
|
+
function preserveLocalCopy(repoDir, path, blob, resolution) {
|
|
80
|
+
const target = freeLocalPath(repoDir, path, resolution.reserved);
|
|
81
|
+
resolution.renames.set(path, target);
|
|
82
|
+
resolution.reserved.add(target);
|
|
83
|
+
resolution.files.set(target, { blob, localPath: path });
|
|
84
|
+
}
|
|
85
|
+
function freeLocalPath(repoDir, path, reserved) {
|
|
86
|
+
const dir = dirname(path);
|
|
87
|
+
const names = [
|
|
88
|
+
...readdirSync(join(repoDir, dir)),
|
|
89
|
+
...[...reserved].filter((entry) => dirname(entry) === dir).map((entry) => basename(entry)),
|
|
90
|
+
];
|
|
91
|
+
const name = basename(path);
|
|
92
|
+
const numbered = /^[A-Z]\d+-(.+)$/s.exec(name);
|
|
93
|
+
const { cycleLetter, fileNumber } = nextFilePosition(names, false);
|
|
94
|
+
const extension = name.endsWith(".summary.md") ? ".summary.md" : extname(name);
|
|
95
|
+
const stem = name.slice(0, name.length - extension.length);
|
|
96
|
+
for (let offset = 0;; ++offset) {
|
|
97
|
+
const candidate = join(dir, numbered
|
|
98
|
+
? `${cycleLetter}${fileNumber + offset}-${numbered[1]}`
|
|
99
|
+
: `${stem}-local-${offset + 2}${extension}`);
|
|
100
|
+
if (reserved.has(candidate) || lstatSync(join(repoDir, candidate), { throwIfNoEntry: false }))
|
|
101
|
+
continue;
|
|
102
|
+
const companion = `${candidate.slice(0, -3)}.summary.md`;
|
|
103
|
+
if (name.endsWith(".md") &&
|
|
104
|
+
reserved.has(`${path.slice(0, -3)}.summary.md`) &&
|
|
105
|
+
(reserved.has(companion) || lstatSync(join(repoDir, companion), { throwIfNoEntry: false })))
|
|
106
|
+
continue;
|
|
107
|
+
return candidate;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function preserveCompanionSummaries(repoDir, remote, local, resolution) {
|
|
111
|
+
for (const [source, target] of resolution.renames) {
|
|
112
|
+
if (!source.endsWith(".md"))
|
|
113
|
+
continue;
|
|
114
|
+
const companion = `${source.slice(0, -3)}.summary.md`;
|
|
115
|
+
const blob = local.get(companion);
|
|
116
|
+
if (!blob)
|
|
117
|
+
continue;
|
|
118
|
+
const companionTarget = `${target.slice(0, -3)}.summary.md`;
|
|
119
|
+
if (resolution.reserved.has(companionTarget) ||
|
|
120
|
+
lstatSync(join(repoDir, companionTarget), { throwIfNoEntry: false }))
|
|
121
|
+
throw new CliError(`Cannot preserve related summary ${companion}: ${companionTarget} exists.`);
|
|
122
|
+
conflictBlob(companion, true, local);
|
|
123
|
+
const previousTarget = resolution.renames.get(companion);
|
|
124
|
+
if (previousTarget !== undefined)
|
|
125
|
+
resolution.files.delete(previousTarget);
|
|
126
|
+
resolution.renames.set(companion, companionTarget);
|
|
127
|
+
resolution.reserved.add(companionTarget);
|
|
128
|
+
resolution.files.set(companionTarget, { blob, localPath: companion });
|
|
129
|
+
const published = conflictBlob(companion, remote.has(companion), remote);
|
|
130
|
+
if (published)
|
|
131
|
+
resolution.files.set(companion, { blob: published });
|
|
132
|
+
else
|
|
133
|
+
resolution.files.delete(companion);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function preserveLocalReferences(repoDir, remote, local, resolution) {
|
|
137
|
+
if (resolution.renames.size === 0)
|
|
138
|
+
return;
|
|
139
|
+
const published = readTree(repoDir, rebaseCommit(repoDir, "onto"));
|
|
140
|
+
const added = new Set(gitOutputRaw(repoDir, "diff", "--cached", "--name-only", "--diff-filter=A", "-z")
|
|
141
|
+
.split("\0")
|
|
142
|
+
.filter((path) => path !== ""));
|
|
143
|
+
for (const [path, blob] of local) {
|
|
144
|
+
if (!path.endsWith(".md") ||
|
|
145
|
+
(!added.has(path) && (!remote.has(path) || published.has(path))) ||
|
|
146
|
+
resolution.renames.has(path))
|
|
147
|
+
continue;
|
|
148
|
+
if (blob.mode === "100644" || blob.mode === "100755")
|
|
149
|
+
resolution.files.set(path, { blob, localPath: path });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function rebaseCommit(repoDir, name) {
|
|
153
|
+
const state = gitOutput(repoDir, "rev-parse", "--git-path", `rebase-merge/${name}`);
|
|
154
|
+
const path = resolve(repoDir, state);
|
|
155
|
+
if (!lstatSync(path, { throwIfNoEntry: false }))
|
|
156
|
+
throw new CliError("Cannot read the original rebase commits. Resolve the rebase manually.");
|
|
157
|
+
return readFileSync(path, "utf8").trim();
|
|
158
|
+
}
|
|
159
|
+
function assertNoLaterEdits(repoDir, renames) {
|
|
160
|
+
if (renames.size === 0)
|
|
161
|
+
return;
|
|
162
|
+
const originalHead = rebaseCommit(repoDir, "orig-head");
|
|
163
|
+
const commits = gitOutput(repoDir, "rev-list", `REBASE_HEAD..${originalHead}`);
|
|
164
|
+
for (const commit of commits.split("\n").filter((value) => value !== "")) {
|
|
165
|
+
const tree = readTree(repoDir, commit);
|
|
166
|
+
const paths = gitOutputRaw(repoDir, "diff-tree", "--no-commit-id", "--name-only", "-r", "-z", commit);
|
|
167
|
+
for (const path of paths.split("\0")) {
|
|
168
|
+
if (renames.has(path))
|
|
169
|
+
throw new CliError(`Cannot automatically rename ${path}: a later local commit edits it. Resolve the rebase manually.`);
|
|
170
|
+
const blob = tree.get(path);
|
|
171
|
+
if (!path.endsWith(".md") || !blob || !["100644", "100755"].includes(blob.mode))
|
|
172
|
+
continue;
|
|
173
|
+
const content = readBlob(repoDir, blob);
|
|
174
|
+
if (!updateReferences(content, path, renames).equals(content))
|
|
175
|
+
throw new CliError(`A later local commit references a renamed document in ${path}. Resolve the rebase manually.`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function readBlob(repoDir, blob) {
|
|
180
|
+
return execFileSync("git", ["-C", repoDir, "cat-file", "blob", blob.id]);
|
|
181
|
+
}
|
|
182
|
+
function updateReferences(original, source, renames) {
|
|
183
|
+
const replacements = new Map();
|
|
184
|
+
for (const [before, after] of renames) {
|
|
185
|
+
replacements.set(before, after);
|
|
186
|
+
const from = relative(dirname(source), before);
|
|
187
|
+
const to = relative(dirname(source), after);
|
|
188
|
+
replacements.set(from, to);
|
|
189
|
+
replacements.set(`./${from}`, `./${to}`);
|
|
190
|
+
// A shared plans clone stores the project's .plans directory under its configured folder.
|
|
191
|
+
replacements.set(before.replace(/^[^/]+\//, ".plans/"), after.replace(/^[^/]+\//, ".plans/"));
|
|
192
|
+
}
|
|
193
|
+
if (replacements.size === 0)
|
|
194
|
+
return original;
|
|
195
|
+
const pattern = [...replacements.keys()]
|
|
196
|
+
.sort((left, right) => right.length - left.length)
|
|
197
|
+
.map((path) => path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
198
|
+
.join("|");
|
|
199
|
+
const text = original.toString("utf8");
|
|
200
|
+
if (!Buffer.from(text).equals(original))
|
|
201
|
+
return original;
|
|
202
|
+
const updated = text.replace(new RegExp(`(?<![\\w./-])(?:${pattern})(?=$|[\\s\x60'"<>()[\\]#:])`, "g"), (match) => replacements.get(match) ?? match);
|
|
203
|
+
return Buffer.from(updated);
|
|
204
|
+
}
|
|
205
|
+
function applyResolution(repoDir, conflicts, resolution) {
|
|
206
|
+
const removed = [...new Set([...conflicts.keys(), ...resolution.renames.keys()])].filter((path) => !resolution.files.has(path));
|
|
207
|
+
for (const path of [...removed, ...resolution.files.keys()])
|
|
208
|
+
assertRegularPath(repoDir, path);
|
|
209
|
+
const writes = [...resolution.files].map(([path, file]) => {
|
|
210
|
+
const original = readBlob(repoDir, file.blob);
|
|
211
|
+
const content = file.localPath !== undefined && path.endsWith(".md")
|
|
212
|
+
? updateReferences(original, file.localPath, resolution.renames)
|
|
213
|
+
: original;
|
|
214
|
+
return { path, content, mode: file.blob.mode === "100755" ? 0o755 : 0o644 };
|
|
215
|
+
});
|
|
216
|
+
for (const path of removed)
|
|
217
|
+
rmSync(join(repoDir, path), { force: true });
|
|
218
|
+
for (const { path, content, mode } of writes) {
|
|
219
|
+
const target = join(repoDir, path);
|
|
220
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
221
|
+
writeFileSync(target, content);
|
|
222
|
+
chmodSync(target, mode);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function assertRegularPath(repoDir, path) {
|
|
226
|
+
const stat = lstatSync(join(repoDir, path), { throwIfNoEntry: false });
|
|
227
|
+
if (stat && !stat.isFile())
|
|
228
|
+
throw new CliError(`Cannot automatically replace ${path}. Resolve the rebase manually.`);
|
|
229
|
+
for (let dir = dirname(path); dir !== "."; dir = dirname(dir)) {
|
|
230
|
+
const parent = lstatSync(join(repoDir, dir), { throwIfNoEntry: false });
|
|
231
|
+
if (parent && !parent.isDirectory())
|
|
232
|
+
throw new CliError(`Cannot automatically write through ${dir}. Resolve the rebase manually.`);
|
|
233
|
+
}
|
|
234
|
+
}
|
package/dist/plans/rebase.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import type { Output } 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(repoDir: string, stdout: Output): 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,12 +11,37 @@ 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 function resolveStoppedRebase(repoDir, stdout) {
|
|
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(repoDir, stdout);
|
|
25
|
+
git(repoDir, "add", "-A");
|
|
26
|
+
continueRebase(repoDir);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function continueRebase(repoDir) {
|
|
30
|
+
const args = ["-c", "core.editor=true", "rebase", "--continue"];
|
|
31
|
+
try {
|
|
32
|
+
git(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
|
+
git(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 [
|