@daldindev/agentic-skills 0.1.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/CHANGELOG.md +24 -0
- package/README.md +42 -2
- package/package.json +1 -1
- package/src/cli.mjs +161 -14
- package/src/diff.mjs +219 -0
- package/src/install.mjs +179 -18
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,30 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.0] - 2026-09-05
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `update` and `sync` now keep both changes when a local edit and an upstream change never wrote in the same place, instead of freezing the file for either. A file is only ever combined when the version it was installed from can be read and hashes to exactly what the manifest recorded, and only when no two changes touch; otherwise it stays frozen, exactly as before. Nothing local is ever dropped to make a merge possible, and no file is left half-combined.
|
|
14
|
+
- `--no-merge` turns that off, leaving every edited file frozen the way earlier versions did.
|
|
15
|
+
- `agentic-skills diff`: for every file a local edit froze, compares three versions - the commit you installed from, your copy, and current upstream - so every difference has a known author. Without a path it lists the frozen files and says whether each is blocked by a real overlap or only by the per-file rule; with a path it prints all three versions of every changed region. Nothing is written, and nothing is merged.
|
|
16
|
+
- `--base-archive` supplies the version you installed from as a local tarball or URL, instead of downloading the commit recorded in the manifest. Used by `update`, `sync`, and `diff`.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- The manifest no longer advances the recorded hash of a file that was skipped. Nothing was written to that file, so the version it descends from has not changed; recording the newer one made it look as though the user had deleted an upstream change they had merely not taken yet.
|
|
21
|
+
|
|
22
|
+
## [0.2.0] - 2026-09-05
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `agentic-skills sync`: installs when the target is missing and updates when it is already there, so one command covers both states. It is the command for a `postinstall` hook, a CI step, or a container build, where the run cannot stop to ask which state it is in. Local edits are preserved as with `update`; `--force` overwrites them, so exit code 2 cannot happen.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- The note about a missing manifest is printed only when files were actually skipped, and now appears for every command rather than only `update`.
|
|
31
|
+
- The error `init` raises on a non-empty target points at `sync` for unattended runs.
|
|
32
|
+
|
|
9
33
|
## [0.1.0] - 2026-09-03
|
|
10
34
|
|
|
11
35
|
### Added
|
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ npx agentic-skills init
|
|
|
31
31
|
Requirements:
|
|
32
32
|
|
|
33
33
|
- Node.js `>=20.11`
|
|
34
|
-
- Network access to `codeload.github.com`, where GitHub serves source archives, when you run `init` or `
|
|
34
|
+
- Network access to `codeload.github.com`, where GitHub serves source archives, when you run `init`, `update`, `sync`, or `diff`
|
|
35
35
|
|
|
36
36
|
## Quick Start
|
|
37
37
|
|
|
@@ -55,7 +55,9 @@ Point your assistant at `.agents/ARCHITECTURE.md`. It lists every component with
|
|
|
55
55
|
| --- | --- |
|
|
56
56
|
| `agentic-skills init` | Download ag-kit and install the content. Refuses a non-empty target unless `--force` |
|
|
57
57
|
| `agentic-skills update` | Download ag-kit again and update installed files, keeping the ones you edited |
|
|
58
|
+
| `agentic-skills sync` | Install if missing, update if present. One command that works in both states, for unattended runs |
|
|
58
59
|
| `agentic-skills status` | Show what is installed, from which upstream commit, and which files were changed locally |
|
|
60
|
+
| `agentic-skills diff` | Show why your edits froze a file, and what they are holding back |
|
|
59
61
|
|
|
60
62
|
| Option | Purpose |
|
|
61
63
|
| --- | --- |
|
|
@@ -63,7 +65,9 @@ Point your assistant at `.agents/ARCHITECTURE.md`. It lists every component with
|
|
|
63
65
|
| `-d, --dir <name>` | Install directory inside the project, default `.agents` |
|
|
64
66
|
| `-r, --ref <git-ref>` | Upstream branch, tag, or commit to install, default `main` |
|
|
65
67
|
| `--archive <source>` | Install from a local ag-kit `.tar.gz` or a URL instead of GitHub |
|
|
68
|
+
| `--base-archive <source>` | The version you installed from, instead of downloading the commit in the manifest |
|
|
66
69
|
| `-f, --force` | Overwrite files that were modified locally |
|
|
70
|
+
| `--no-merge` | Never combine a local edit with an upstream change; leave every edited file frozen |
|
|
67
71
|
| `--dry-run` | Download and print the plan without writing anything |
|
|
68
72
|
| `--json` | Print machine-readable output |
|
|
69
73
|
|
|
@@ -82,16 +86,51 @@ For each file it compares what is installed, what upstream now has, and the hash
|
|
|
82
86
|
| Situation | Result |
|
|
83
87
|
| --- | --- |
|
|
84
88
|
| You never touched the file | Updated, or deleted when upstream dropped it |
|
|
85
|
-
| You edited
|
|
89
|
+
| You edited it somewhere upstream did not | Both changes kept |
|
|
90
|
+
| You edited it where upstream also changed it | Skipped and listed, unless `--force` |
|
|
86
91
|
| You created the file yourself | Never touched |
|
|
87
92
|
| Upstream added a file | Installed |
|
|
88
93
|
|
|
94
|
+
Both are only kept when the two changes can be told apart and never touch. Anywhere they do touch, or where the version you installed from cannot be read, the file stays frozen instead. **Nothing you wrote is ever dropped to make that work**, and a file is never left half-combined: it is reconciled whole or left exactly as it was. `--no-merge` turns the attempt off entirely.
|
|
95
|
+
|
|
96
|
+
`diff` shows what a skipped file is holding back, and whose text is whose. It reads three versions — the commit you installed from, your copy, and current upstream — so every difference has a known author:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
npx @daldindev/agentic-skills diff
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
That lists the frozen files and what each one is stuck on. Pass a path to see the three versions of every changed region in one file.
|
|
103
|
+
|
|
104
|
+
Nothing is written and nothing is merged, so the decision stays yours: fold the upstream change in by hand, or take upstream and lose your edit with `--force`. Until you do one or the other, that file sits out every update.
|
|
105
|
+
|
|
89
106
|
To stay on a known upstream state instead of `main`, pass a tag or commit. The same flag reproduces an earlier install exactly:
|
|
90
107
|
|
|
91
108
|
```bash
|
|
92
109
|
npx @daldindev/agentic-skills update --ref v2026.8.31
|
|
93
110
|
```
|
|
94
111
|
|
|
112
|
+
### Unattended runs
|
|
113
|
+
|
|
114
|
+
`sync` reconciles the install against upstream whatever state it is in: it creates the directory when it is missing and updates it when it is already there. Nothing has to be known about the target beforehand, which is what makes it the command to put where no one is watching — a `postinstall` hook, a CI step, a container build:
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"scripts": {
|
|
119
|
+
"postinstall": "agentic-skills sync"
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Every `npm i` then brings the install to the current upstream, on a fresh clone and on a checkout that already has the content alike.
|
|
125
|
+
|
|
126
|
+
Your edits survive it: a file you changed is kept, listed, and the command exits `2`. Reach for `--force` only when the install is a derived tree your project regenerates and never edits by hand, `.agents/` in `.gitignore` for instance:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
agentic-skills sync --force
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Copying that second line into a project where `.agents/` is committed means losing an edited skill on every `npm i`, silently. And `--force` only removes exit `2`: a download that cannot reach GitHub still exits `1`.
|
|
133
|
+
|
|
95
134
|
## What gets installed
|
|
96
135
|
|
|
97
136
|
| Upstream path | Installed as |
|
|
@@ -127,6 +166,7 @@ In scope:
|
|
|
127
166
|
- Downloading the upstream roles, skills, and workflows and installing them anywhere
|
|
128
167
|
- Updating an install while preserving local edits
|
|
129
168
|
- Recording upstream provenance with every install
|
|
169
|
+
- Running unattended, from a postinstall hook or a CI step, with one command that works in either state
|
|
130
170
|
|
|
131
171
|
Out of scope:
|
|
132
172
|
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
6
|
import { PACKAGE_ROOT, readJson } from "./fs-utils.mjs";
|
|
7
|
-
import { DEFAULT_INSTALL_DIR, init, status, update } from "./install.mjs";
|
|
7
|
+
import { DEFAULT_INSTALL_DIR, init, inspect, status, sync, update } from "./install.mjs";
|
|
8
8
|
import { UPSTREAM, extractPort, loadArchive, materialize } from "./upstream.mjs";
|
|
9
9
|
|
|
10
10
|
const HELP = `agentic-skills - install the ag-kit agent roles, skills, and workflows into any project
|
|
@@ -12,14 +12,21 @@ const HELP = `agentic-skills - install the ag-kit agent roles, skills, and workf
|
|
|
12
12
|
Usage:
|
|
13
13
|
agentic-skills init [options] Download ag-kit and install the content into <path>/<dir>
|
|
14
14
|
agentic-skills update [options] Download ag-kit again and update installed files, preserving local edits
|
|
15
|
+
agentic-skills sync [options] Install if missing, update if present; safe to run unattended
|
|
15
16
|
agentic-skills status [options] Show what is installed and which files were changed locally
|
|
17
|
+
agentic-skills diff [path] Show why your edits froze a file, and what they are holding back
|
|
16
18
|
|
|
17
19
|
Options:
|
|
18
20
|
-p, --path <dir> Project directory (default: current directory)
|
|
19
21
|
-d, --dir <name> Install directory inside the project (default: ${DEFAULT_INSTALL_DIR})
|
|
20
22
|
-r, --ref <git-ref> Upstream branch, tag, or commit to install (default: ${UPSTREAM.ref})
|
|
21
23
|
--archive <source> Install from a local ag-kit .tar.gz or a URL instead of GitHub
|
|
24
|
+
--base-archive <s> The version you installed from, as a local .tar.gz or URL, instead of
|
|
25
|
+
downloading the commit in the manifest. Used to tell your changes
|
|
26
|
+
from upstream's, by update, sync, and diff
|
|
22
27
|
-f, --force Overwrite locally modified files
|
|
28
|
+
--no-merge Never combine a local edit with an upstream change; leave every
|
|
29
|
+
edited file frozen, whether or not the two touch
|
|
23
30
|
--dry-run Download and show the plan without writing anything
|
|
24
31
|
--json Print machine-readable output
|
|
25
32
|
-h, --help Show this help
|
|
@@ -39,7 +46,9 @@ const { values, positionals } = parseArgs({
|
|
|
39
46
|
dir: { type: "string", short: "d" },
|
|
40
47
|
ref: { type: "string", short: "r", default: UPSTREAM.ref },
|
|
41
48
|
archive: { type: "string" },
|
|
49
|
+
"base-archive": { type: "string" },
|
|
42
50
|
force: { type: "boolean", short: "f", default: false },
|
|
51
|
+
"no-merge": { type: "boolean", default: false },
|
|
43
52
|
"dry-run": { type: "boolean", default: false },
|
|
44
53
|
json: { type: "boolean", default: false },
|
|
45
54
|
help: { type: "boolean", short: "h", default: false },
|
|
@@ -58,10 +67,10 @@ const list = (label, items, format = (item) => item) => {
|
|
|
58
67
|
for (const item of items) console.log(` ${format(item)}`);
|
|
59
68
|
};
|
|
60
69
|
|
|
61
|
-
/** Download
|
|
62
|
-
const
|
|
63
|
-
log(
|
|
64
|
-
const buffer = await loadArchive({ ref
|
|
70
|
+
/** Download one upstream archive and lay the ported tree out in a temp dir. */
|
|
71
|
+
const fetchTree = async ({ ref = null, archive = null }) => {
|
|
72
|
+
log(archive ? `Reading ${archive}` : `Downloading ${UPSTREAM.repository} at ${ref}`);
|
|
73
|
+
const buffer = await loadArchive({ ref, archive });
|
|
65
74
|
const port = extractPort(buffer);
|
|
66
75
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agentic-skills-"));
|
|
67
76
|
try {
|
|
@@ -75,7 +84,7 @@ const fetchIncoming = async () => {
|
|
|
75
84
|
dir,
|
|
76
85
|
upstream: {
|
|
77
86
|
repository: UPSTREAM.repository,
|
|
78
|
-
ref:
|
|
87
|
+
ref: archive ? null : ref,
|
|
79
88
|
commit: port.commit,
|
|
80
89
|
version: port.version,
|
|
81
90
|
},
|
|
@@ -83,6 +92,31 @@ const fetchIncoming = async () => {
|
|
|
83
92
|
};
|
|
84
93
|
};
|
|
85
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The version the install came from, so both sides of a local edit can be told
|
|
97
|
+
* apart. Called only once something is actually frozen, and never fatal: without
|
|
98
|
+
* it the run just stays as conservative as it was before.
|
|
99
|
+
*
|
|
100
|
+
* `--archive` means this run was asked not to reach for the network, so the base
|
|
101
|
+
* has to come from `--base-archive` or not at all.
|
|
102
|
+
*/
|
|
103
|
+
let loadedBase = null;
|
|
104
|
+
const loadBase = async (manifest) => {
|
|
105
|
+
const archive = values["base-archive"];
|
|
106
|
+
if (!archive && values.archive) return null;
|
|
107
|
+
|
|
108
|
+
const commit = manifest?.upstream?.commit;
|
|
109
|
+
if (!archive && !commit) return null;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
loadedBase = await fetchTree(archive ? { archive } : { ref: commit });
|
|
113
|
+
return loadedBase;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error(`warning: could not read the version you installed from: ${error.message}`);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
86
120
|
const describeUpstream = (upstream) => {
|
|
87
121
|
if (!upstream) return "unknown upstream";
|
|
88
122
|
const commit = upstream.commit ? ` (${upstream.commit.slice(0, 7)})` : "";
|
|
@@ -95,19 +129,101 @@ const printPlan = (result) => {
|
|
|
95
129
|
console.log(`${prefix}${result.mode}: ${result.installDir}`);
|
|
96
130
|
console.log(`${prefix}source: ${describeUpstream(result.upstream)}`);
|
|
97
131
|
console.log(
|
|
98
|
-
`${prefix}added ${plan.add.length}, updated ${plan.update.length},
|
|
99
|
-
`
|
|
132
|
+
`${prefix}added ${plan.add.length}, updated ${plan.update.length}, merged ${plan.merge.length}, ` +
|
|
133
|
+
`removed ${plan.remove.length}, skipped ${plan.skip.length}, kept ${plan.keep.length}, ` +
|
|
134
|
+
`unchanged ${plan.unchanged.length}`,
|
|
100
135
|
);
|
|
101
|
-
list("
|
|
136
|
+
list("Merged (your change and upstream's did not touch)", plan.merge, (item) => item.file);
|
|
137
|
+
list("Skipped (left frozen; use --force to overwrite)", plan.skip, (item) => `${item.file}: ${item.reason}`);
|
|
102
138
|
list("Kept (removed upstream but modified locally)", plan.keep, (item) => `${item.file}: ${item.reason}`);
|
|
103
|
-
if (result.
|
|
139
|
+
if (result.hadManifest === false && plan.skip.length) {
|
|
104
140
|
console.log("\nNo manifest was found, so files that differ from upstream were skipped. Re-run with --force to overwrite them.");
|
|
105
141
|
}
|
|
106
|
-
if (result.
|
|
142
|
+
if (result.created && !result.dryRun) {
|
|
107
143
|
console.log(`\nPoint your assistant at ${path.join(result.installDir, "ARCHITECTURE.md")} to get the inventory.`);
|
|
108
144
|
}
|
|
109
145
|
};
|
|
110
146
|
|
|
147
|
+
const SIDE = { base: "base ", yours: "yours", upstream: "upstr" };
|
|
148
|
+
const REGION_LINES = 20;
|
|
149
|
+
const plural = (count, word) => `${count} ${count === 1 ? word : `${word}s`}`;
|
|
150
|
+
|
|
151
|
+
const authorPhrase = (authors) => {
|
|
152
|
+
if (authors.length === 2) return "both sides wrote here";
|
|
153
|
+
return authors[0] === "yours" ? "only you wrote here" : "only upstream wrote here";
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** One side of a region. A bare `|` is a blank line; no `|` means the side has nothing here. */
|
|
157
|
+
const printSide = (side, lines) => {
|
|
158
|
+
if (!lines.length) {
|
|
159
|
+
console.log(` ${SIDE[side]} nothing here`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
for (const line of lines.slice(0, REGION_LINES)) console.log(` ${SIDE[side]} | ${line}`);
|
|
163
|
+
const rest = lines.length - REGION_LINES;
|
|
164
|
+
if (rest > 0) console.log(` ${SIDE[side]} ~ ${plural(rest, "more line")} ~`);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const printDiffFile = (report, entry) => {
|
|
168
|
+
console.log(entry.file);
|
|
169
|
+
console.log(` installed ${describeUpstream(report.base)} -> upstream ${describeUpstream(report.upstream)}`);
|
|
170
|
+
console.log(` ${plural(entry.regions.length, "region")} changed, ${entry.collisions} written by both sides`);
|
|
171
|
+
|
|
172
|
+
entry.regions.forEach((region, index) => {
|
|
173
|
+
console.log(`\n [${index + 1}] ${region.where.padEnd(38)} ${authorPhrase(region.authors)}`);
|
|
174
|
+
printSide("base", region.base);
|
|
175
|
+
printSide("yours", region.yours);
|
|
176
|
+
printSide("upstream", region.upstream);
|
|
177
|
+
if (region.authors.length === 2) {
|
|
178
|
+
console.log("\n note: no untouched line separates your lines from upstream's here,");
|
|
179
|
+
console.log(" so neither side can be applied without deciding about the other.");
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
console.log("\ndiff only reads - nothing was written.");
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const printDiffSummary = (report) => {
|
|
187
|
+
console.log(`diff: ${report.installDir}`);
|
|
188
|
+
console.log(`source: ${describeUpstream(report.upstream)}`);
|
|
189
|
+
if (report.comparedAgainstBase) console.log(`base: ${describeUpstream(report.base)}`);
|
|
190
|
+
|
|
191
|
+
const entries = [...report.frozen, ...report.removedUpstream];
|
|
192
|
+
if (!entries.length) {
|
|
193
|
+
console.log("\nNothing is frozen: every installed file either matches upstream, tracks it cleanly, or is your own.");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const width = Math.max(...entries.map((entry) => entry.file.length));
|
|
198
|
+
if (!report.comparedAgainstBase) {
|
|
199
|
+
console.log(`\n${plural(entries.length, "frozen file")}, and no way to tell your changes from upstream's:`);
|
|
200
|
+
console.log(report.hadManifest
|
|
201
|
+
? "the version this install came from could not be read. Pass --base-archive to supply it."
|
|
202
|
+
: "this install has no manifest saying which version it came from.");
|
|
203
|
+
for (const entry of entries) console.log(` ${entry.file}`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const blocked = report.frozen.filter((entry) => entry.collisions > 0).length;
|
|
208
|
+
const byRule = report.frozen.length - blocked;
|
|
209
|
+
const parts = [];
|
|
210
|
+
if (blocked) parts.push(`${blocked} blocked by an overlap`);
|
|
211
|
+
if (byRule) parts.push(`${byRule} frozen by the per-file rule alone`);
|
|
212
|
+
if (report.removedUpstream.length) parts.push(`${report.removedUpstream.length} removed upstream`);
|
|
213
|
+
|
|
214
|
+
console.log(`\n${plural(entries.length, "frozen file")}. ${parts.join(", ")}.`);
|
|
215
|
+
console.log("diff only reads - nothing was written.\n");
|
|
216
|
+
|
|
217
|
+
for (const entry of report.frozen) {
|
|
218
|
+
const both = entry.collisions ? `${entry.collisions} written by both sides` : "none written by both sides";
|
|
219
|
+
console.log(` ${entry.file.padEnd(width)} ${plural(entry.regions.length, "region")} changed, ${both}`);
|
|
220
|
+
}
|
|
221
|
+
for (const entry of report.removedUpstream) {
|
|
222
|
+
console.log(` ${entry.file.padEnd(width)} removed upstream, kept because you edited it`);
|
|
223
|
+
}
|
|
224
|
+
console.log('\nRun "agentic-skills diff <path>" to see the three versions of one file.');
|
|
225
|
+
};
|
|
226
|
+
|
|
111
227
|
const printStatus = (info) => {
|
|
112
228
|
console.log(`package: ${info.package.packageName} ${info.package.packageVersion}`);
|
|
113
229
|
console.log(`target: ${info.installDir}`);
|
|
@@ -133,8 +249,9 @@ try {
|
|
|
133
249
|
console.log(pkg.version);
|
|
134
250
|
} else if (values.help || !command || command === "help") {
|
|
135
251
|
process.stdout.write(HELP);
|
|
136
|
-
} else if (command === "init" || command === "update") {
|
|
137
|
-
const
|
|
252
|
+
} else if (command === "init" || command === "update" || command === "sync") {
|
|
253
|
+
const merging = !values.force && !values["no-merge"];
|
|
254
|
+
const incoming = await fetchTree({ ref: values.ref, archive: values.archive });
|
|
138
255
|
try {
|
|
139
256
|
const options = {
|
|
140
257
|
path: values.path,
|
|
@@ -142,14 +259,44 @@ try {
|
|
|
142
259
|
force: values.force,
|
|
143
260
|
dryRun: values["dry-run"],
|
|
144
261
|
incomingDir: incoming.dir,
|
|
262
|
+
loadBase: merging ? loadBase : null,
|
|
145
263
|
upstream: incoming.upstream,
|
|
146
264
|
};
|
|
147
|
-
const
|
|
265
|
+
const run = { init, update, sync }[command];
|
|
266
|
+
const result = await run(options);
|
|
148
267
|
if (values.json) console.log(JSON.stringify(result, null, 2));
|
|
149
268
|
else printPlan(result);
|
|
150
269
|
if (result.plan.skip.length || result.plan.keep.length) process.exitCode = 2;
|
|
151
270
|
} finally {
|
|
152
271
|
await incoming.cleanup();
|
|
272
|
+
if (loadedBase) await loadedBase.cleanup();
|
|
273
|
+
}
|
|
274
|
+
} else if (command === "diff") {
|
|
275
|
+
const target = { path: values.path, dir: values.dir };
|
|
276
|
+
const incoming = await fetchTree({ ref: values.ref, archive: values.archive });
|
|
277
|
+
try {
|
|
278
|
+
const report = await inspect({
|
|
279
|
+
...target,
|
|
280
|
+
incomingDir: incoming.dir,
|
|
281
|
+
loadBase,
|
|
282
|
+
upstream: incoming.upstream,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const wanted = positionals[1]?.replaceAll("\\", "/");
|
|
286
|
+
if (values.json) console.log(JSON.stringify(report, null, 2));
|
|
287
|
+
else if (!wanted) printDiffSummary(report);
|
|
288
|
+
else {
|
|
289
|
+
const entry = report.frozen.find((item) => item.file === wanted);
|
|
290
|
+
if (entry?.regions) printDiffFile(report, entry);
|
|
291
|
+
else if (entry) console.error(`${wanted} is frozen, but the version you installed from could not be read.`);
|
|
292
|
+
else {
|
|
293
|
+
console.error(`${wanted} is not frozen. Run "agentic-skills diff" to list the files that are.`);
|
|
294
|
+
process.exitCode = 1;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
} finally {
|
|
298
|
+
await incoming.cleanup();
|
|
299
|
+
if (loadedBase) await loadedBase.cleanup();
|
|
153
300
|
}
|
|
154
301
|
} else if (command === "status") {
|
|
155
302
|
const info = await status({ path: values.path, dir: values.dir });
|
package/src/diff.mjs
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three-way comparison of one file, used to explain why a local edit froze it.
|
|
3
|
+
*
|
|
4
|
+
* Everything is measured against the version the user installed from, so every
|
|
5
|
+
* difference has a known author: the installed tree changed it, upstream
|
|
6
|
+
* changed it, or both wrote in the same place. A two-way comparison cannot
|
|
7
|
+
* tell those apart, which is the whole reason this exists.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here merges anything. It only describes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Above this many table cells the alignment degrades to "replaced wholesale". */
|
|
13
|
+
const MAX_CELLS = 4_000_000;
|
|
14
|
+
|
|
15
|
+
export const toLines = (text) => {
|
|
16
|
+
const lines = text.split(/\r?\n/);
|
|
17
|
+
if (lines.length && lines[lines.length - 1] === "") lines.pop();
|
|
18
|
+
return lines;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const mark = (type) => (line) => ({ type, line });
|
|
22
|
+
|
|
23
|
+
/** Longest common subsequence alignment of two line arrays. */
|
|
24
|
+
function align(a, b) {
|
|
25
|
+
if (!a.length) return b.map(mark("add"));
|
|
26
|
+
if (!b.length) return a.map(mark("remove"));
|
|
27
|
+
if (a.length * b.length > MAX_CELLS) return [...a.map(mark("remove")), ...b.map(mark("add"))];
|
|
28
|
+
|
|
29
|
+
const width = b.length + 1;
|
|
30
|
+
const table = new Uint32Array((a.length + 1) * width);
|
|
31
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
32
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
33
|
+
table[i * width + j] = a[i] === b[j]
|
|
34
|
+
? table[(i + 1) * width + j + 1] + 1
|
|
35
|
+
: Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ops = [];
|
|
40
|
+
let i = 0;
|
|
41
|
+
let j = 0;
|
|
42
|
+
while (i < a.length && j < b.length) {
|
|
43
|
+
if (a[i] === b[j]) {
|
|
44
|
+
ops.push({ type: "context", line: a[i] });
|
|
45
|
+
i++;
|
|
46
|
+
j++;
|
|
47
|
+
} else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
|
|
48
|
+
ops.push({ type: "remove", line: a[i++] });
|
|
49
|
+
} else {
|
|
50
|
+
ops.push({ type: "add", line: b[j++] });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
while (i < a.length) ops.push({ type: "remove", line: a[i++] });
|
|
54
|
+
while (j < b.length) ops.push({ type: "add", line: b[j++] });
|
|
55
|
+
return ops;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Compare two texts line by line. Identical head and tail are trimmed first,
|
|
60
|
+
* so an edit in one section does not pay for the rest of the file.
|
|
61
|
+
*/
|
|
62
|
+
export function diffLines(before, after) {
|
|
63
|
+
const a = toLines(before);
|
|
64
|
+
const b = toLines(after);
|
|
65
|
+
|
|
66
|
+
let start = 0;
|
|
67
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
68
|
+
|
|
69
|
+
let endA = a.length;
|
|
70
|
+
let endB = b.length;
|
|
71
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
72
|
+
endA--;
|
|
73
|
+
endB--;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return [
|
|
77
|
+
...a.slice(0, start).map(mark("context")),
|
|
78
|
+
...align(a.slice(start, endA), b.slice(start, endB)),
|
|
79
|
+
...a.slice(endA).map(mark("context")),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Rewrite the comparison as an edit script against the base: a list of
|
|
85
|
+
* `[start, end)` base ranges and the lines that replace each one. An insertion
|
|
86
|
+
* is an empty range, which is what lets two insertions at the same point be
|
|
87
|
+
* recognised as the same place.
|
|
88
|
+
*/
|
|
89
|
+
function editsAgainstBase(baseLines, otherLines) {
|
|
90
|
+
// Trim the identical head and tail first, so a one-line change in a long file
|
|
91
|
+
// never pays for the whole file, and never trips the size cap in `align`.
|
|
92
|
+
let start = 0;
|
|
93
|
+
while (start < baseLines.length && start < otherLines.length && baseLines[start] === otherLines[start]) start++;
|
|
94
|
+
|
|
95
|
+
let endBase = baseLines.length;
|
|
96
|
+
let endOther = otherLines.length;
|
|
97
|
+
while (endBase > start && endOther > start && baseLines[endBase - 1] === otherLines[endOther - 1]) {
|
|
98
|
+
endBase--;
|
|
99
|
+
endOther--;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const ops = align(baseLines.slice(start, endBase), otherLines.slice(start, endOther));
|
|
103
|
+
const edits = [];
|
|
104
|
+
let index = start;
|
|
105
|
+
let current = null;
|
|
106
|
+
|
|
107
|
+
for (const op of ops) {
|
|
108
|
+
if (op.type === "context") {
|
|
109
|
+
if (current) edits.push(current);
|
|
110
|
+
current = null;
|
|
111
|
+
index++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
current ||= { start: index, end: index, lines: [] };
|
|
115
|
+
if (op.type === "remove") current.end = ++index;
|
|
116
|
+
else current.lines.push(op.line);
|
|
117
|
+
}
|
|
118
|
+
if (current) edits.push(current);
|
|
119
|
+
|
|
120
|
+
return edits;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Replay one side's edits over a slice of the base. */
|
|
124
|
+
function applyEdits(baseLines, start, end, edits) {
|
|
125
|
+
const out = [];
|
|
126
|
+
let cursor = start;
|
|
127
|
+
for (const edit of edits) {
|
|
128
|
+
out.push(...baseLines.slice(cursor, edit.start));
|
|
129
|
+
out.push(...edit.lines);
|
|
130
|
+
cursor = edit.end;
|
|
131
|
+
}
|
|
132
|
+
out.push(...baseLines.slice(cursor, end));
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Every place the two sides diverged from the base, with all three versions of
|
|
138
|
+
* that place and who wrote there.
|
|
139
|
+
*
|
|
140
|
+
* Ranges that touch are one region, the way a merge tool treats them: two
|
|
141
|
+
* changes with no untouched line between them cannot be applied independently.
|
|
142
|
+
*/
|
|
143
|
+
export function changedRegions({ base, yours, upstream }) {
|
|
144
|
+
const baseLines = toLines(base);
|
|
145
|
+
const sides = {
|
|
146
|
+
yours: editsAgainstBase(baseLines, toLines(yours)),
|
|
147
|
+
upstream: editsAgainstBase(baseLines, toLines(upstream)),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const all = Object.entries(sides)
|
|
151
|
+
.flatMap(([side, edits]) => edits.map((edit) => ({ ...edit, side })))
|
|
152
|
+
.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
153
|
+
|
|
154
|
+
const grouped = [];
|
|
155
|
+
for (const edit of all) {
|
|
156
|
+
const last = grouped[grouped.length - 1];
|
|
157
|
+
if (last && edit.start <= last.end) {
|
|
158
|
+
last.end = Math.max(last.end, edit.end);
|
|
159
|
+
last.parts.push(edit);
|
|
160
|
+
} else {
|
|
161
|
+
grouped.push({ start: edit.start, end: edit.end, parts: [edit] });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return grouped.map((region) => {
|
|
166
|
+
const authors = [...new Set(region.parts.map((part) => part.side))].sort();
|
|
167
|
+
const partsFor = (side) => region.parts.filter((part) => part.side === side);
|
|
168
|
+
return {
|
|
169
|
+
baseStart: region.start,
|
|
170
|
+
baseEnd: region.end,
|
|
171
|
+
authors,
|
|
172
|
+
base: baseLines.slice(region.start, region.end),
|
|
173
|
+
yours: applyEdits(baseLines, region.start, region.end, partsFor("yours")),
|
|
174
|
+
upstream: applyEdits(baseLines, region.start, region.end, partsFor("upstream")),
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Combine both sides when, and only when, they never wrote in the same place.
|
|
181
|
+
*
|
|
182
|
+
* A region with one author is unambiguous: taking that author's text is the
|
|
183
|
+
* same operation `update` performs on a file nobody edited, with the scope
|
|
184
|
+
* corrected from the file to the region. A region with two authors has no
|
|
185
|
+
* answer that is not a guess, so the whole file is refused rather than
|
|
186
|
+
* half-merged. Nothing the user wrote is ever dropped by this function.
|
|
187
|
+
*/
|
|
188
|
+
export function mergeClean({ base, yours, upstream }) {
|
|
189
|
+
const regions = changedRegions({ base, yours, upstream });
|
|
190
|
+
const conflicts = regions.filter((region) => region.authors.length === 2);
|
|
191
|
+
if (conflicts.length) return { merged: null, conflicts: conflicts.length };
|
|
192
|
+
|
|
193
|
+
const baseLines = toLines(base);
|
|
194
|
+
const out = [];
|
|
195
|
+
let cursor = 0;
|
|
196
|
+
for (const region of regions) {
|
|
197
|
+
out.push(...baseLines.slice(cursor, region.baseStart));
|
|
198
|
+
out.push(...(region.authors[0] === "yours" ? region.yours : region.upstream));
|
|
199
|
+
cursor = region.baseEnd;
|
|
200
|
+
}
|
|
201
|
+
out.push(...baseLines.slice(cursor));
|
|
202
|
+
|
|
203
|
+
// The result replaces the reader's file, so it keeps the reader's line endings
|
|
204
|
+
// and their choice about a final newline. A merge should change what it says,
|
|
205
|
+
// not how every line in the file is terminated.
|
|
206
|
+
const eol = yours.includes("\r\n") ? "\r\n" : "\n";
|
|
207
|
+
const trailing = /\r?\n$/.test(yours) ? eol : "";
|
|
208
|
+
return { merged: out.length ? out.join(eol) + trailing : "", conflicts: 0 };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The nearest Markdown heading above a region, to say where in the file it sits. */
|
|
212
|
+
export function locate(base, region) {
|
|
213
|
+
const baseLines = toLines(base);
|
|
214
|
+
if (region.baseStart >= baseLines.length) return "end of file";
|
|
215
|
+
for (let i = region.baseStart; i >= 0; i--) {
|
|
216
|
+
if (baseLines[i].startsWith("#")) return `line ${region.baseStart + 1}, in "${baseLines[i].trim()}"`;
|
|
217
|
+
}
|
|
218
|
+
return `line ${region.baseStart + 1}`;
|
|
219
|
+
}
|
package/src/install.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { changedRegions, locate, mergeClean } from "./diff.mjs";
|
|
3
4
|
import {
|
|
4
5
|
PACKAGE_ROOT,
|
|
5
6
|
copyFile,
|
|
6
7
|
exists,
|
|
8
|
+
hashFile,
|
|
7
9
|
readJson,
|
|
8
10
|
removeEmptyParents,
|
|
9
11
|
snapshotTree,
|
|
@@ -54,6 +56,29 @@ async function writeManifest(installDir, files, { previous = null, upstream = nu
|
|
|
54
56
|
return manifest;
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
/**
|
|
60
|
+
* What each installed file descends from after this run.
|
|
61
|
+
*
|
|
62
|
+
* Only files this run actually wrote move to the incoming hash. A file left
|
|
63
|
+
* frozen keeps the hash it had, because its ancestor is still the version it was
|
|
64
|
+
* last written from; recording the new one would make a later merge read
|
|
65
|
+
* upstream's change as the user having deleted it.
|
|
66
|
+
*/
|
|
67
|
+
function nextManifestFiles({ previous, plan }) {
|
|
68
|
+
const kept = new Set(plan.keep.map((item) => item.file));
|
|
69
|
+
const files = {};
|
|
70
|
+
|
|
71
|
+
// Carry an entry forward only while the file is still something to track:
|
|
72
|
+
// upstream still ships it, or it is on disk because an edit saved it.
|
|
73
|
+
for (const [file, hash] of Object.entries(previous)) {
|
|
74
|
+
if (plan.incoming[file] !== undefined || kept.has(file)) files[file] = hash;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const file of [...plan.add, ...plan.update, ...plan.unchanged]) files[file] = plan.incoming[file];
|
|
78
|
+
for (const { file } of plan.merge ?? []) files[file] = plan.incoming[file];
|
|
79
|
+
return files;
|
|
80
|
+
}
|
|
81
|
+
|
|
57
82
|
/**
|
|
58
83
|
* Three-way comparison between the installed tree, the incoming tree fetched
|
|
59
84
|
* from upstream, and the hashes recorded at the last install or update.
|
|
@@ -103,10 +128,53 @@ export async function planUpdate({ installDir, incomingDir, manifest = null, for
|
|
|
103
128
|
return plan;
|
|
104
129
|
}
|
|
105
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Try to combine both sides for the files the plan would otherwise skip.
|
|
133
|
+
*
|
|
134
|
+
* A file is only ever a merge candidate when the downloaded base hashes to the
|
|
135
|
+
* exact value the manifest recorded for it. That proves the text being used as
|
|
136
|
+
* the common ancestor is the text that was installed; without that proof the
|
|
137
|
+
* file is left frozen, because a wrong ancestor is how a merge loses work.
|
|
138
|
+
*/
|
|
139
|
+
export async function resolveMerges({ installDir, incomingDir, baseDir, plan, manifest }) {
|
|
140
|
+
if (!baseDir || !manifest) return { merge: [], skip: plan.skip };
|
|
141
|
+
|
|
142
|
+
const merge = [];
|
|
143
|
+
const skip = [];
|
|
144
|
+
|
|
145
|
+
for (const item of plan.skip) {
|
|
146
|
+
const recorded = manifest.files?.[item.file];
|
|
147
|
+
const basePath = path.join(baseDir, item.file);
|
|
148
|
+
const provable = recorded && (await exists(basePath)) && (await hashFile(basePath)) === recorded;
|
|
149
|
+
if (!provable) {
|
|
150
|
+
skip.push({ ...item, reason: `${item.reason}; no proven common ancestor to merge from` });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const [base, yours, incoming] = await Promise.all([
|
|
155
|
+
fs.readFile(basePath, "utf8"),
|
|
156
|
+
fs.readFile(path.join(installDir, item.file), "utf8"),
|
|
157
|
+
fs.readFile(path.join(incomingDir, item.file), "utf8"),
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
const { merged, conflicts } = mergeClean({ base, yours, upstream: incoming });
|
|
161
|
+
if (merged === null) {
|
|
162
|
+
skip.push({ ...item, reason: `your change and upstream's overlap in ${conflicts === 1 ? "one place" : `${conflicts} places`}` });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
merge.push({ file: item.file, text: merged });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { merge, skip };
|
|
169
|
+
}
|
|
170
|
+
|
|
106
171
|
export async function applyPlan({ installDir, incomingDir, plan }) {
|
|
107
172
|
for (const file of [...plan.add, ...plan.update]) {
|
|
108
173
|
await copyFile(path.join(incomingDir, file), path.join(installDir, file));
|
|
109
174
|
}
|
|
175
|
+
for (const { file, text } of plan.merge ?? []) {
|
|
176
|
+
await fs.writeFile(path.join(installDir, file), text);
|
|
177
|
+
}
|
|
110
178
|
for (const file of plan.remove) {
|
|
111
179
|
const target = path.join(installDir, file);
|
|
112
180
|
await fs.rm(target, { force: true });
|
|
@@ -114,35 +182,106 @@ export async function applyPlan({ installDir, incomingDir, plan }) {
|
|
|
114
182
|
}
|
|
115
183
|
}
|
|
116
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Where the version this install came from can be read, if anywhere. `loadBase`
|
|
187
|
+
* is only called once the plan actually has something frozen, so an install with
|
|
188
|
+
* no local edits never pays for a second download.
|
|
189
|
+
*/
|
|
190
|
+
async function baseFor(options, plan, manifest) {
|
|
191
|
+
if (options.baseDir) return { dir: options.baseDir, upstream: options.base || null };
|
|
192
|
+
if (!plan.skip.length || !options.loadBase) return null;
|
|
193
|
+
return (await options.loadBase(manifest)) || null;
|
|
194
|
+
}
|
|
195
|
+
|
|
117
196
|
async function hasContent(dir) {
|
|
118
197
|
if (!(await exists(dir))) return false;
|
|
119
198
|
const entries = await fs.readdir(dir);
|
|
120
199
|
return entries.length > 0;
|
|
121
200
|
}
|
|
122
201
|
|
|
123
|
-
|
|
202
|
+
/**
|
|
203
|
+
* Plan against the incoming tree, apply it, and record the manifest.
|
|
204
|
+
* The three commands differ only in the preconditions they enforce first.
|
|
205
|
+
*/
|
|
206
|
+
async function reconcile(mode, options) {
|
|
124
207
|
const { installDir } = resolveTarget(options);
|
|
125
208
|
const { incomingDir, upstream = null } = options;
|
|
126
|
-
const force = Boolean(options.force);
|
|
127
209
|
|
|
128
|
-
|
|
210
|
+
const created = !(await hasContent(installDir));
|
|
211
|
+
const manifest = await loadManifest(installDir);
|
|
212
|
+
const plan = await planUpdate({ installDir, incomingDir, manifest, force: Boolean(options.force) });
|
|
213
|
+
|
|
214
|
+
const base = await baseFor(options, plan, manifest);
|
|
215
|
+
const resolved = await resolveMerges({
|
|
216
|
+
installDir,
|
|
217
|
+
incomingDir,
|
|
218
|
+
baseDir: base?.dir || null,
|
|
219
|
+
plan,
|
|
220
|
+
manifest,
|
|
221
|
+
});
|
|
222
|
+
plan.merge = resolved.merge;
|
|
223
|
+
plan.skip = resolved.skip;
|
|
224
|
+
|
|
225
|
+
if (!options.dryRun) {
|
|
226
|
+
await applyPlan({ installDir, incomingDir, plan });
|
|
227
|
+
const files = nextManifestFiles({ previous: manifest?.files || {}, plan });
|
|
228
|
+
await writeManifest(installDir, files, { previous: manifest, upstream });
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
mode,
|
|
232
|
+
installDir,
|
|
233
|
+
plan,
|
|
234
|
+
upstream,
|
|
235
|
+
created,
|
|
236
|
+
hadManifest: Boolean(manifest),
|
|
237
|
+
dryRun: Boolean(options.dryRun),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function init(options = {}) {
|
|
242
|
+
const { installDir } = resolveTarget(options);
|
|
243
|
+
|
|
244
|
+
if (!options.force && (await hasContent(installDir))) {
|
|
129
245
|
throw new Error(
|
|
130
246
|
`${installDir} already exists and is not empty. Run "agentic-skills update" to update it, ` +
|
|
131
|
-
"or pass --force to overwrite managed files in place.
|
|
247
|
+
'"agentic-skills sync" if this runs unattended, or pass --force to overwrite managed files in place.',
|
|
132
248
|
);
|
|
133
249
|
}
|
|
134
250
|
|
|
135
|
-
|
|
136
|
-
const plan = await planUpdate({ installDir, incomingDir, manifest, force });
|
|
137
|
-
if (!options.dryRun) {
|
|
138
|
-
await applyPlan({ installDir, incomingDir, plan });
|
|
139
|
-
await writeManifest(installDir, plan.incoming, { previous: manifest, upstream });
|
|
140
|
-
}
|
|
141
|
-
return { mode: "init", installDir, plan, upstream, dryRun: Boolean(options.dryRun) };
|
|
251
|
+
return reconcile("init", options);
|
|
142
252
|
}
|
|
143
253
|
|
|
144
254
|
export async function update(options = {}) {
|
|
145
255
|
const { installDir } = resolveTarget(options);
|
|
256
|
+
|
|
257
|
+
if (!(await exists(installDir))) {
|
|
258
|
+
throw new Error(`${installDir} does not exist. Run "agentic-skills init" first.`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return reconcile("update", options);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Reconcile whatever state the target is in: install it when missing, update it
|
|
266
|
+
* when it is already there. Neither precondition applies, so this is the command
|
|
267
|
+
* to run unattended - a postinstall, a CI step, a container build - where one
|
|
268
|
+
* line has to work on a fresh clone and on an existing tree alike. Local edits
|
|
269
|
+
* are preserved exactly as with update, and --force overwrites them.
|
|
270
|
+
*/
|
|
271
|
+
export async function sync(options = {}) {
|
|
272
|
+
return reconcile("sync", options);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Report what upstream changed in the files a local edit froze.
|
|
277
|
+
*
|
|
278
|
+
* `status` says which files stopped receiving updates; this says what they
|
|
279
|
+
* stopped receiving. Nothing is written, and nothing is merged: the installer
|
|
280
|
+
* never reconciles an edited file on its own, so this is the input a person
|
|
281
|
+
* needs to do it by hand.
|
|
282
|
+
*/
|
|
283
|
+
export async function inspect(options = {}) {
|
|
284
|
+
const { installDir } = resolveTarget(options);
|
|
146
285
|
const { incomingDir, upstream = null } = options;
|
|
147
286
|
|
|
148
287
|
if (!(await exists(installDir))) {
|
|
@@ -150,18 +289,40 @@ export async function update(options = {}) {
|
|
|
150
289
|
}
|
|
151
290
|
|
|
152
291
|
const manifest = await loadManifest(installDir);
|
|
153
|
-
const plan = await planUpdate({ installDir, incomingDir, manifest, force:
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
292
|
+
const plan = await planUpdate({ installDir, incomingDir, manifest, force: false });
|
|
293
|
+
const base = await baseFor(options, plan, manifest);
|
|
294
|
+
const baseDir = base?.dir || null;
|
|
295
|
+
const read = async (dir, file) => (dir ? fs.readFile(path.join(dir, file), "utf8").catch(() => null) : null);
|
|
296
|
+
|
|
297
|
+
const frozen = [];
|
|
298
|
+
for (const { file, reason } of plan.skip) {
|
|
299
|
+
const yours = await read(installDir, file);
|
|
300
|
+
const incoming = await read(incomingDir, file);
|
|
301
|
+
// A file upstream added after this install has no base text; treat it as empty
|
|
302
|
+
// so both sides read as having written the whole thing, which is the truth.
|
|
303
|
+
const baseText = (await read(baseDir, file)) ?? "";
|
|
304
|
+
|
|
305
|
+
const regions = baseDir
|
|
306
|
+
? changedRegions({ base: baseText, yours, upstream: incoming })
|
|
307
|
+
.map((region) => ({ ...region, where: locate(baseText, region) }))
|
|
308
|
+
: null;
|
|
309
|
+
|
|
310
|
+
frozen.push({
|
|
311
|
+
file,
|
|
312
|
+
reason,
|
|
313
|
+
regions,
|
|
314
|
+
collisions: regions ? regions.filter((region) => region.authors.length === 2).length : null,
|
|
315
|
+
});
|
|
157
316
|
}
|
|
317
|
+
|
|
158
318
|
return {
|
|
159
|
-
mode: "update",
|
|
160
319
|
installDir,
|
|
161
|
-
plan,
|
|
162
320
|
upstream,
|
|
321
|
+
base: base?.upstream || null,
|
|
322
|
+
comparedAgainstBase: Boolean(baseDir),
|
|
163
323
|
hadManifest: Boolean(manifest),
|
|
164
|
-
|
|
324
|
+
frozen,
|
|
325
|
+
removedUpstream: plan.keep,
|
|
165
326
|
};
|
|
166
327
|
}
|
|
167
328
|
|