feat(hashline): allowed file deletion and movement operations
- Implemented file deletion (`REM`) and movement (`MV`) operations within the hashline grammar and parser. - Added filesystem support for executing delete and move commands while maintaining snapshot history migration. - Integrated file operation detection and parsing logic into the coding agent and patcher components. - Added comprehensive integration tests and documentation for the new file-level operation syntax.
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Added support for file deletion and moving within file editing operations
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -12529,4 +12532,4 @@ Initial public release.
|
||||
|
||||
## [0.7.6] - 2025-11-13
|
||||
|
||||
Previous releases did not maintain a changelog.
|
||||
Previous releases did not maintain a changelog.
|
||||
@@ -107,6 +107,23 @@ function formatBlockResolution(resolution: BlockResolution): string {
|
||||
}
|
||||
|
||||
function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsResult | undefined): RenderedSection {
|
||||
if (result.op === "delete") {
|
||||
const toolResult: AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema> = {
|
||||
content: [{ type: "text", text: `Deleted ${result.path}` }],
|
||||
details: {
|
||||
diff: "",
|
||||
op: "delete",
|
||||
path: result.path,
|
||||
oldText: result.before,
|
||||
meta: outputMeta().get(),
|
||||
},
|
||||
};
|
||||
return {
|
||||
toolResult,
|
||||
perFileResult: { path: result.path, diff: "", op: "delete", oldText: result.before },
|
||||
};
|
||||
}
|
||||
|
||||
if (result.op === "noop") {
|
||||
const toolResult: AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema> = {
|
||||
content: [{ type: "text", text: noChangeDiagnostic(result.path) }],
|
||||
@@ -130,24 +147,37 @@ function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsR
|
||||
result.blockResolutions && result.blockResolutions.length > 0
|
||||
? `\n${result.blockResolutions.map(formatBlockResolution).join("\n")}`
|
||||
: "";
|
||||
const moveBlock = result.moveDest ? `\nMoved to ${result.moveDest}` : "";
|
||||
const firstChangedLine = result.firstChangedLine ?? diff.firstChangedLine;
|
||||
return {
|
||||
toolResult: {
|
||||
content: [{ type: "text", text: `${result.header}${blockBlock}${previewBlock}${warningsBlock}` }],
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${result.header}${blockBlock}${moveBlock}${previewBlock}${warningsBlock}`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
diff: diff.diff,
|
||||
firstChangedLine,
|
||||
diagnostics,
|
||||
op: result.op,
|
||||
move: result.moveDest,
|
||||
path: result.moveDest ?? result.path,
|
||||
oldText: result.before,
|
||||
newText: result.after,
|
||||
meta,
|
||||
},
|
||||
},
|
||||
perFileResult: {
|
||||
path: result.path,
|
||||
path: result.moveDest ?? result.path,
|
||||
diff: diff.diff,
|
||||
firstChangedLine,
|
||||
diagnostics,
|
||||
op: result.op,
|
||||
move: result.moveDest,
|
||||
oldText: result.before,
|
||||
newText: result.after,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
* (batch request, diagnostics) lives on the instance and isn't safe to
|
||||
* share across concurrent edit tools.
|
||||
*/
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { Filesystem, NotFoundError, type WriteResult } from "@oh-my-pi/hashline";
|
||||
import { Filesystem, NotFoundError, type PreflightWriteOptions, type WriteResult } from "@oh-my-pi/hashline";
|
||||
import { isEnoent } from "@oh-my-pi/pi-utils";
|
||||
import type { FileDiagnosticsResult, WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
|
||||
import type { ToolSession } from "../../tools";
|
||||
@@ -122,10 +123,45 @@ export class HashlineFilesystem extends Filesystem {
|
||||
return content;
|
||||
}
|
||||
|
||||
async preflightWrite(relativePath: string): Promise<void> {
|
||||
async preflightWrite(relativePath: string, options?: PreflightWriteOptions): Promise<void> {
|
||||
const fileOp = options?.fileOp;
|
||||
if (fileOp?.kind === "rem") {
|
||||
enforcePlanModeWrite(this.session, relativePath, { op: "delete" });
|
||||
return;
|
||||
}
|
||||
if (fileOp?.kind === "move") {
|
||||
enforcePlanModeWrite(this.session, relativePath, { op: "update", move: fileOp.dest });
|
||||
return;
|
||||
}
|
||||
enforcePlanModeWrite(this.session, relativePath, { op: "update" });
|
||||
}
|
||||
|
||||
async delete(relativePath: string): Promise<void> {
|
||||
enforcePlanModeWrite(this.session, relativePath, { op: "delete" });
|
||||
const absolutePath = this.resolveAbsolute(relativePath);
|
||||
try {
|
||||
await fs.rm(absolutePath);
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) throw new NotFoundError(relativePath, error);
|
||||
throw error;
|
||||
}
|
||||
invalidateFsScanAfterWrite(absolutePath);
|
||||
}
|
||||
|
||||
async move(fromRelative: string, toRelative: string, content?: string): Promise<void> {
|
||||
enforcePlanModeWrite(this.session, fromRelative, { op: "update", move: toRelative });
|
||||
const fromAbsolute = this.resolveAbsolute(fromRelative);
|
||||
const toAbsolute = this.resolveAbsolute(toRelative);
|
||||
if (content !== undefined) {
|
||||
await Bun.write(toAbsolute, content);
|
||||
await fs.rm(fromAbsolute);
|
||||
} else {
|
||||
await fs.rename(fromAbsolute, toAbsolute);
|
||||
}
|
||||
invalidateFsScanAfterWrite(fromAbsolute);
|
||||
invalidateFsScanAfterWrite(toAbsolute);
|
||||
}
|
||||
|
||||
async writeText(relativePath: string, content: string): Promise<WriteResult> {
|
||||
await this.preflightWrite(relativePath);
|
||||
const absolutePath = this.resolveAbsolute(relativePath);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { scheduler } from "node:timers/promises";
|
||||
import { isPromise } from "node:util/types";
|
||||
|
||||
import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
|
||||
import { Patch } from "@oh-my-pi/hashline";
|
||||
import {
|
||||
type AfterToolCallContext,
|
||||
type AfterToolCallResult,
|
||||
@@ -939,6 +940,15 @@ function getEditDestructiveIntent(args: unknown): { kind: "delete" | "move"; pat
|
||||
|
||||
const input = getStringProperty(a, "input");
|
||||
if (input) {
|
||||
try {
|
||||
const patch = Patch.parse(input);
|
||||
for (const section of patch.sections) {
|
||||
if (section.fileOp?.kind === "rem") return { kind: "delete", paths: [section.path] };
|
||||
if (section.fileOp?.kind === "move") return { kind: "move", paths: [section.path, section.fileOp.dest] };
|
||||
}
|
||||
} catch {
|
||||
// Not a hashline patch — fall through to apply_patch parsing.
|
||||
}
|
||||
try {
|
||||
const entries = expandApplyPatchToEntries({ input });
|
||||
const deleteEntry = entries.find(entry => entry.op === "delete");
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Added `REM` section op to delete files
|
||||
- Added `MV` section op to rename or move files, including snapshot history migration
|
||||
- Added whole-file `REM` and `MV DEST` section ops so a hashline patch can delete or rename/move files (optionally after line edits) without leaving the edit tool. `MV` relocates read/snapshot history to the destination path.
|
||||
|
||||
## [16.1.23] - 2026-06-26
|
||||
|
||||
@@ -312,4 +317,4 @@ All notable changes to this package will be documented in this file.
|
||||
|
||||
- Fixed repeated patch application mutating cached `after_anchor` edits between target snapshots
|
||||
- Fixed multi-section patching to preflight write policies and reject duplicate canonical targets before any section is committed
|
||||
- Fixed mixed line-ending restoration to preserve the first newline style instead of rewriting ties to LF
|
||||
- Fixed mixed line-ending restoration to preserve the first newline style instead of rewriting ties to LF
|
||||
@@ -52,6 +52,8 @@ Inside a section:
|
||||
- `DEL A.=B` / `DEL.BLK A` — delete concrete lines or a resolved block.
|
||||
- `INS.PRE A:` / `INS.POST A:` / `INS.HEAD:` / `INS.TAIL:` — insert following body rows.
|
||||
- `INS.BLK.POST A:` — insert following body rows after the resolved block's last line.
|
||||
- `REM` — delete the whole file named by the section header.
|
||||
- `MV DEST` — move/rename the section file to `DEST` (optionally after line edits).
|
||||
- `+TEXT` — literal body row (use `+` alone for a blank line).
|
||||
|
||||
## Abstractions
|
||||
|
||||
@@ -33,6 +33,10 @@ export const HL_REPLACE_BLOCK_KEYWORD = "SWAP.BLK";
|
||||
export const HL_DELETE_BLOCK_KEYWORD = "DEL.BLK";
|
||||
/** Hunk-header keyword: `INS.BLK.POST N:` inserts after the last line of the tree-sitter block at N. */
|
||||
export const HL_INSERT_AFTER_BLOCK_KEYWORD = "INS.BLK.POST";
|
||||
/** File-level keyword: `REM` deletes the whole file named by the section header. */
|
||||
export const HL_REM_KEYWORD = "REM";
|
||||
/** File-level keyword: `MV DEST` renames/moves the section file to `DEST`. */
|
||||
export const HL_MOVE_KEYWORD = "MV";
|
||||
export const HL_HEADER_COLON = ":";
|
||||
|
||||
/** Separator between a hashline file path and its opaque snapshot tag. */
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* {@link Filesystem.readText} and {@link Filesystem.writeText}; the FS deals
|
||||
* only in raw text strings.
|
||||
*/
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as pathModule from "node:path";
|
||||
|
||||
/**
|
||||
@@ -20,6 +21,13 @@ export interface WriteResult {
|
||||
text: string;
|
||||
}
|
||||
|
||||
import type { FileOp } from "./types";
|
||||
|
||||
/** Optional hints for {@link Filesystem.preflightWrite}. */
|
||||
export interface PreflightWriteOptions {
|
||||
fileOp?: FileOp;
|
||||
}
|
||||
|
||||
/**
|
||||
* ENOENT-like error thrown by {@link Filesystem.readText} when a path is
|
||||
* missing. Carrying a `code` property keeps the contract compatible with
|
||||
@@ -58,11 +66,25 @@ export abstract class Filesystem {
|
||||
abstract readText(path: string): Promise<string>;
|
||||
|
||||
/** Validate that `path` is writable before a prepared batch starts committing. */
|
||||
async preflightWrite(_path: string): Promise<void> {}
|
||||
async preflightWrite(_path: string, _options?: PreflightWriteOptions): Promise<void> {}
|
||||
|
||||
/** Persist `content` at `path`. Returns the actual final text that was written. */
|
||||
abstract writeText(path: string, content: string): Promise<WriteResult>;
|
||||
|
||||
/** Delete the file at `path`. Default: not supported. */
|
||||
async delete(path: string): Promise<void> {
|
||||
throw new Error(`Filesystem does not support delete: ${path}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move/rename `from` to `to`. When `content` is provided the destination
|
||||
* receives that text; otherwise implementations may preserve the source bytes.
|
||||
*/
|
||||
async move(from: string, to: string, content?: string): Promise<void> {
|
||||
void content;
|
||||
throw new Error(`Filesystem does not support move: ${from} -> ${to}`);
|
||||
}
|
||||
|
||||
/** Return true when the path exists and can be read. Default: probe via {@link readText}. */
|
||||
async exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -125,6 +147,18 @@ export class InMemoryFilesystem extends Filesystem {
|
||||
return { text: content };
|
||||
}
|
||||
|
||||
async delete(path: string): Promise<void> {
|
||||
if (!this.#files.delete(path)) throw new NotFoundError(path);
|
||||
}
|
||||
|
||||
async move(from: string, to: string, content?: string): Promise<void> {
|
||||
const existing = this.#files.get(from);
|
||||
if (existing === undefined) throw new NotFoundError(from);
|
||||
const finalContent = content ?? existing;
|
||||
this.#files.set(to, finalContent);
|
||||
this.#files.delete(from);
|
||||
}
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
return this.#files.has(path);
|
||||
}
|
||||
@@ -172,6 +206,29 @@ export class NodeFilesystem extends Filesystem {
|
||||
return { text: content };
|
||||
}
|
||||
|
||||
async delete(path: string): Promise<void> {
|
||||
try {
|
||||
await fs.rm(path);
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) throw new NotFoundError(path, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async move(from: string, to: string, content?: string): Promise<void> {
|
||||
if (content !== undefined) {
|
||||
await Bun.write(to, content);
|
||||
await this.delete(from);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fs.rename(from, to);
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) throw new NotFoundError(from, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
canonicalPath(path: string): string {
|
||||
return pathModule.resolve(path);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,15 @@ file_header: "[" filename "#" file_hash "]" LF
|
||||
file_hash: /[0-9A-F]{4}/
|
||||
filename: /[^#\r\n]+/
|
||||
|
||||
hunk: replace_hunk | replace_block_hunk | insert_hunk | insert_block_hunk | delete_hunk | delete_block_hunk
|
||||
hunk: replace_hunk | replace_block_hunk | insert_hunk | insert_block_hunk | delete_hunk | delete_block_hunk | remove_hunk | move_hunk
|
||||
replace_hunk: replace_anchor LF emit_op*
|
||||
replace_block_hunk: replace_block_anchor LF emit_op+
|
||||
insert_hunk: insert_anchor LF emit_op+
|
||||
insert_block_hunk: insert_block_anchor LF emit_op+
|
||||
delete_hunk: "DEL " header_range LF
|
||||
delete_block_hunk: "DEL.BLK " LID LF
|
||||
remove_hunk: "REM" LF
|
||||
move_hunk: "MV " filename LF emit_op*
|
||||
replace_anchor: "SWAP " header_range ":"
|
||||
replace_block_anchor: "SWAP.BLK " LID ":"
|
||||
insert_anchor: "INS." insert_pos ":"
|
||||
|
||||
@@ -13,7 +13,7 @@ import { resolveBlockEdits } from "./block";
|
||||
import { HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
|
||||
import { parsePatch, parsePatchStreaming } from "./parser";
|
||||
import { Tokenizer } from "./tokenizer";
|
||||
import type { ApplyResult, BlockResolver, Edit, SplitOptions } from "./types";
|
||||
import type { ApplyResult, BlockResolver, Edit, FileOp, SplitOptions } from "./types";
|
||||
|
||||
// Pure classification — single shared tokenizer is safe.
|
||||
const TOKENIZER = new Tokenizer();
|
||||
@@ -237,7 +237,7 @@ export class PatchSection {
|
||||
readonly path: string;
|
||||
readonly fileHash: string | undefined;
|
||||
readonly diff: string;
|
||||
#parsed: { edits: Edit[]; warnings: string[] } | undefined;
|
||||
#parsed: { edits: Edit[]; fileOp?: FileOp; warnings: string[] } | undefined;
|
||||
|
||||
constructor(raw: RawSection) {
|
||||
this.path = raw.path;
|
||||
@@ -247,12 +247,21 @@ export class PatchSection {
|
||||
|
||||
/**
|
||||
* Parse this section's diff body. Cached: subsequent calls return the
|
||||
* same `{ edits, warnings }` object so callers can safely call this from
|
||||
* same `{ edits, fileOp?, warnings }` object so callers can safely call this from
|
||||
* multiple paths (preflight, apply, diff-preview).
|
||||
*/
|
||||
parse(): { edits: Edit[]; warnings: readonly string[] } {
|
||||
parse(): { edits: Edit[]; fileOp?: FileOp; warnings: readonly string[] } {
|
||||
this.#parsed ??= parsePatch(this.diff);
|
||||
return this.#parsed;
|
||||
const parsed = this.#parsed;
|
||||
const fileOp =
|
||||
parsed.fileOp === undefined
|
||||
? undefined
|
||||
: parsed.fileOp.kind === "move"
|
||||
? { kind: "move" as const, dest: normalizeHashlinePath(parsed.fileOp.dest) }
|
||||
: parsed.fileOp;
|
||||
return fileOp === parsed.fileOp
|
||||
? parsed
|
||||
: { edits: parsed.edits, ...(fileOp === undefined ? {} : { fileOp }), warnings: parsed.warnings };
|
||||
}
|
||||
|
||||
/** Parsed edits for this section. */
|
||||
@@ -260,6 +269,11 @@ export class PatchSection {
|
||||
return this.parse().edits;
|
||||
}
|
||||
|
||||
/** Optional whole-file operation (`REM` / `MV`). */
|
||||
get fileOp(): FileOp | undefined {
|
||||
return this.parse().fileOp;
|
||||
}
|
||||
|
||||
/** Warnings emitted during parsing of this section. */
|
||||
get warnings(): readonly string[] {
|
||||
return this.parse().warnings;
|
||||
|
||||
@@ -119,6 +119,14 @@ export const UNRESOLVED_BLOCK_INTERNAL =
|
||||
/** Delete hunk received a body row. */
|
||||
export const DELETE_TAKES_NO_BODY = `\`DEL N${HL_RANGE_SEP}M\` does not take body rows. Remove the body, or use \`SWAP N${HL_RANGE_SEP}M:\`.`;
|
||||
|
||||
/** `REM` received a body row or coexists with line edits. */
|
||||
export const REM_TAKES_NO_BODY =
|
||||
"`REM` deletes the whole file and takes no body rows or line ops. Issue it alone under the header.";
|
||||
|
||||
/** `MV` received a body row. */
|
||||
export const MOVE_TAKES_NO_BODY =
|
||||
"`MV DEST` does not take body rows. Put line edits above the `MV` row; the destination path follows `MV` on the same line.";
|
||||
|
||||
/** `delete_block N` hunk received a body row. */
|
||||
export const DELETE_BLOCK_TAKES_NO_BODY = "`DEL.BLK N` does not take body rows. Remove the body, or use `SWAP.BLK N:`.";
|
||||
|
||||
|
||||
@@ -11,10 +11,12 @@ import {
|
||||
EMPTY_BLOCK,
|
||||
EMPTY_INSERT,
|
||||
MINUS_ROW_REJECTED,
|
||||
MOVE_TAKES_NO_BODY,
|
||||
REM_TAKES_NO_BODY,
|
||||
} from "./messages";
|
||||
import { stripOneLeadingHashlinePrefix } from "./prefixes";
|
||||
import { type BlockTarget, cloneCursor, type ParsedRange, type Token, Tokenizer } from "./tokenizer";
|
||||
import type { Anchor, Cursor, Edit } from "./types";
|
||||
import type { Anchor, Cursor, Edit, FileOp } from "./types";
|
||||
|
||||
function validateRangeOrder(range: ParsedRange, lineNum: number): void {
|
||||
if (range.end.line < range.start.line) {
|
||||
@@ -110,6 +112,7 @@ export class Executor {
|
||||
#warnings: string[] = [];
|
||||
#editIndex = 0;
|
||||
#pending: Pending | undefined;
|
||||
#fileOp: FileOp | undefined;
|
||||
#terminated = false;
|
||||
#skippableComments: PendingComment[] = [];
|
||||
|
||||
@@ -161,27 +164,47 @@ export class Executor {
|
||||
if (token.target.kind === "replace" || token.target.kind === "delete") {
|
||||
validateRangeOrder(token.target.range, token.lineNum);
|
||||
}
|
||||
if (token.target.kind === "rem") {
|
||||
this.#flushPending();
|
||||
this.#setFileOp({ kind: "rem" }, token.lineNum);
|
||||
return;
|
||||
}
|
||||
if (token.target.kind === "move") {
|
||||
this.#flushPending();
|
||||
this.#setFileOp({ kind: "move", dest: token.target.dest }, token.lineNum);
|
||||
return;
|
||||
}
|
||||
this.#flushPending();
|
||||
this.#pending = { target: token.target, lineNum: token.lineNum, payloads: [], deferredBlanks: [] };
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
end(): { edits: Edit[]; warnings: string[] } {
|
||||
end(): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } {
|
||||
this.#consumePendingSkippableComments();
|
||||
this.#flushPending();
|
||||
this.#validateFileOp();
|
||||
this.#validateNoOverlappingDeletes();
|
||||
return { edits: this.#edits, warnings: this.#warnings };
|
||||
return {
|
||||
edits: this.#edits,
|
||||
...(this.#fileOp === undefined ? {} : { fileOp: this.#fileOp }),
|
||||
warnings: this.#warnings,
|
||||
};
|
||||
}
|
||||
|
||||
endStreaming(): { edits: Edit[]; warnings: string[] } {
|
||||
endStreaming(): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } {
|
||||
this.#consumePendingSkippableComments();
|
||||
if (this.#pending && this.#pending.payloads.length > 0) this.#flushPending();
|
||||
else if (this.#pending?.target.kind === "delete" || this.#pending?.target.kind === "delete_block")
|
||||
this.#flushPending();
|
||||
else this.#pending = undefined;
|
||||
this.#validateFileOp();
|
||||
this.#validateNoOverlappingDeletes();
|
||||
return { edits: this.#edits, warnings: this.#warnings };
|
||||
return {
|
||||
edits: this.#edits,
|
||||
...(this.#fileOp === undefined ? {} : { fileOp: this.#fileOp }),
|
||||
warnings: this.#warnings,
|
||||
};
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
@@ -189,10 +212,30 @@ export class Executor {
|
||||
this.#warnings = [];
|
||||
this.#editIndex = 0;
|
||||
this.#pending = undefined;
|
||||
this.#fileOp = undefined;
|
||||
this.#skippableComments = [];
|
||||
this.#terminated = false;
|
||||
}
|
||||
|
||||
#setFileOp(fileOp: FileOp, lineNum: number): void {
|
||||
if (this.#fileOp !== undefined) {
|
||||
throw new Error(
|
||||
`line ${lineNum}: only one file-level op (\`REM\` or \`MV\`) per section. Merge them under one header.`,
|
||||
);
|
||||
}
|
||||
if (fileOp.kind === "rem" && this.#edits.length > 0) {
|
||||
throw new Error(`line ${lineNum}: ${REM_TAKES_NO_BODY}`);
|
||||
}
|
||||
this.#fileOp = fileOp;
|
||||
}
|
||||
|
||||
#validateFileOp(): void {
|
||||
if (this.#fileOp?.kind !== "rem") return;
|
||||
if (this.#edits.length > 0) {
|
||||
throw new Error("`REM` deletes the whole file and cannot be combined with line ops.");
|
||||
}
|
||||
}
|
||||
|
||||
#validateNoOverlappingDeletes(): void {
|
||||
const sourceLinesByAnchor = new Map<number, number[]>();
|
||||
for (const edit of this.#edits) {
|
||||
@@ -217,6 +260,7 @@ export class Executor {
|
||||
#handleLiteralPayload(text: string, lineNum: number): void {
|
||||
const pending = this.#pending;
|
||||
if (!pending) {
|
||||
if (this.#fileOp !== undefined) throw new Error(`line ${lineNum}: ${MOVE_TAKES_NO_BODY}`);
|
||||
throw new Error(
|
||||
`line ${lineNum}: payload line has no preceding hunk header. ` +
|
||||
`Got ${JSON.stringify(`${HL_PAYLOAD_REPLACE}${text}`)}.`,
|
||||
@@ -231,6 +275,7 @@ export class Executor {
|
||||
#handleRaw(text: string, lineNum: number): void {
|
||||
const contamination = detectApplyPatchContamination(text, this.#pending !== undefined);
|
||||
if (contamination !== null) throw new Error(`line ${lineNum}: ${contamination}`);
|
||||
if (this.#fileOp !== undefined) throw new Error(`line ${lineNum}: ${MOVE_TAKES_NO_BODY}`);
|
||||
if (this.#pending) {
|
||||
if (text.trim().length === 0) {
|
||||
this.#handleBlank(text, lineNum);
|
||||
@@ -390,19 +435,19 @@ export class Executor {
|
||||
}
|
||||
}
|
||||
|
||||
function drain(executor: Executor, tokenizer: Tokenizer): { edits: Edit[]; warnings: string[] } {
|
||||
function drain(executor: Executor, tokenizer: Tokenizer): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } {
|
||||
for (const token of tokenizer.end()) executor.feed(token);
|
||||
return executor.end();
|
||||
}
|
||||
|
||||
export function parsePatch(diff: string): { edits: Edit[]; warnings: string[] } {
|
||||
export function parsePatch(diff: string): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } {
|
||||
const tokenizer = new Tokenizer();
|
||||
const executor = new Executor();
|
||||
for (const token of tokenizer.feed(diff)) executor.feed(token);
|
||||
return drain(executor, tokenizer);
|
||||
}
|
||||
|
||||
export function parsePatchStreaming(diff: string): { edits: Edit[]; warnings: string[] } {
|
||||
export function parsePatchStreaming(diff: string): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } {
|
||||
const tokenizer = new Tokenizer();
|
||||
const executor = new Executor();
|
||||
for (const token of tokenizer.feed(diff)) executor.feed(token);
|
||||
|
||||
@@ -39,7 +39,7 @@ import { MismatchError } from "./mismatch";
|
||||
import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
|
||||
import { Recovery, type RecoveryResult } from "./recovery";
|
||||
import type { SnapshotStore } from "./snapshots";
|
||||
import type { ApplyResult, BlockResolution, BlockResolver, Edit } from "./types";
|
||||
import type { ApplyResult, BlockResolution, BlockResolver, Edit, FileOp } from "./types";
|
||||
|
||||
export interface PatcherOptions {
|
||||
/** Storage backend used for all reads and writes. */
|
||||
@@ -60,8 +60,8 @@ export interface PatchSectionResult {
|
||||
path: string;
|
||||
/** Filesystem-canonical key for this section (e.g. absolute path). */
|
||||
canonicalPath: string;
|
||||
/** `"noop"` when the apply produced no change; otherwise `"create"` / `"update"`. */
|
||||
op: "create" | "update" | "noop";
|
||||
/** `"noop"` when the apply produced no change; `"delete"` removes the file; otherwise `"create"` / `"update"`. */
|
||||
op: "create" | "update" | "delete" | "noop";
|
||||
/** Pre-edit text (LF-normalized, BOM-stripped). */
|
||||
before: string;
|
||||
/** Post-edit text (LF-normalized, BOM-stripped). For `"noop"` equals `before`. */
|
||||
@@ -78,6 +78,8 @@ export interface PatchSectionResult {
|
||||
firstChangedLine?: number;
|
||||
/** Warnings collected by the parser, applier, and (optionally) recovery. */
|
||||
warnings: string[];
|
||||
/** Destination path when this section includes `MV DEST`. */
|
||||
moveDest?: string;
|
||||
/**
|
||||
* Resolved spans for any `replace_block`/`delete_block` ops, present when the
|
||||
* apply matched the tagged content. Undefined for patches with no block ops
|
||||
@@ -107,11 +109,12 @@ export class PreparedSection {
|
||||
readonly normalized: string,
|
||||
readonly applyResult: ApplyResult,
|
||||
readonly parseWarnings: readonly string[],
|
||||
readonly fileOp: FileOp | undefined,
|
||||
) {}
|
||||
|
||||
/** Convenience: returns true when the apply produced no change. */
|
||||
/** Convenience: returns true when the apply produced no change and no file op. */
|
||||
get isNoop(): boolean {
|
||||
return this.applyResult.text === this.normalized;
|
||||
return this.fileOp === undefined && this.applyResult.text === this.normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,7 +254,9 @@ export class Patcher {
|
||||
* tag mismatch ({@link MismatchError}).
|
||||
*/
|
||||
async prepare(section: PatchSection): Promise<PreparedSection> {
|
||||
const parseWarnings = [...section.parse().warnings];
|
||||
const parsed = section.parse();
|
||||
const parseWarnings = [...parsed.warnings];
|
||||
const fileOp = parsed.fileOp;
|
||||
assertSectionHashPresent(section.path, section.fileHash);
|
||||
|
||||
let target = section;
|
||||
@@ -280,23 +285,36 @@ export class Patcher {
|
||||
// Gate the final (possibly recovered) target before any write work, so
|
||||
// an unrecoverable read-only target (e.g. a plan-mode working-tree path)
|
||||
// fails with the write guard rather than a misleading "file not found".
|
||||
await this.fs.preflightWrite(target.path);
|
||||
await this.fs.preflightWrite(target.path, { fileOp });
|
||||
|
||||
if (!read.exists) {
|
||||
throw new Error(`File not found: ${target.path}. Use the write tool to create new files.`);
|
||||
}
|
||||
|
||||
if (fileOp?.kind === "move" && this.fs.canonicalPath(fileOp.dest) === canonicalPath) {
|
||||
throw new Error(`MV destination is the same as ${target.path}.`);
|
||||
}
|
||||
|
||||
const { bom, text } = stripBom(read.rawContent);
|
||||
const lineEnding = detectLineEnding(text);
|
||||
const normalized = normalizeToLF(text);
|
||||
|
||||
const applyResult = this.#applyWithRecovery({
|
||||
section: target,
|
||||
canonicalPath,
|
||||
exists: read.exists,
|
||||
normalized,
|
||||
edits: target.parse().edits,
|
||||
});
|
||||
const applyResult =
|
||||
fileOp?.kind === "rem"
|
||||
? this.#applyWithRecovery({
|
||||
section: target,
|
||||
canonicalPath,
|
||||
exists: read.exists,
|
||||
normalized,
|
||||
edits: [],
|
||||
})
|
||||
: this.#applyWithRecovery({
|
||||
section: target,
|
||||
canonicalPath,
|
||||
exists: read.exists,
|
||||
normalized,
|
||||
edits: parsed.edits,
|
||||
});
|
||||
|
||||
return new PreparedSection(
|
||||
target,
|
||||
@@ -308,6 +326,7 @@ export class Patcher {
|
||||
normalized,
|
||||
applyResult,
|
||||
parseWarnings,
|
||||
fileOp,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -350,11 +369,31 @@ export class Patcher {
|
||||
* filesystem-canonical path.
|
||||
*/
|
||||
async commit(prepared: PreparedSection): Promise<PatchSectionResult> {
|
||||
const { section, normalized, bom, lineEnding, parseWarnings, exists, applyResult, canonicalPath } = prepared;
|
||||
const { section, normalized, bom, lineEnding, parseWarnings, exists, applyResult, canonicalPath, fileOp } =
|
||||
prepared;
|
||||
const after = applyResult.text;
|
||||
const warnings = mergeWarnings(parseWarnings, applyResult.warnings);
|
||||
const moveDest = fileOp?.kind === "move" ? fileOp.dest : undefined;
|
||||
const resultPath = moveDest ?? section.path;
|
||||
|
||||
if (after === normalized) {
|
||||
if (fileOp?.kind === "rem") {
|
||||
await this.fs.delete(section.path);
|
||||
this.snapshots.invalidate(canonicalPath);
|
||||
return {
|
||||
path: section.path,
|
||||
canonicalPath,
|
||||
op: "delete",
|
||||
before: normalized,
|
||||
after: normalized,
|
||||
persisted: prepared.rawContent,
|
||||
written: prepared.rawContent,
|
||||
fileHash: computeFileHash(normalized),
|
||||
header: formatHashlineHeader(section.path, computeFileHash(normalized)),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if (after === normalized && moveDest === undefined) {
|
||||
const hash = this.#recordFullSnapshot(canonicalPath, normalized);
|
||||
return {
|
||||
path: section.path,
|
||||
@@ -371,6 +410,29 @@ export class Patcher {
|
||||
}
|
||||
|
||||
const persisted = bom + restoreLineEndings(after, lineEnding);
|
||||
|
||||
if (moveDest !== undefined) {
|
||||
const destCanonical = this.fs.canonicalPath(moveDest);
|
||||
this.snapshots.relocate(canonicalPath, destCanonical);
|
||||
await this.fs.move(section.path, moveDest, persisted);
|
||||
const fileHash = this.#recordFullSnapshot(destCanonical, after);
|
||||
return {
|
||||
path: resultPath,
|
||||
canonicalPath: destCanonical,
|
||||
op: "update",
|
||||
before: normalized,
|
||||
after,
|
||||
persisted,
|
||||
written: persisted,
|
||||
fileHash,
|
||||
header: formatHashlineHeader(moveDest, fileHash),
|
||||
firstChangedLine: applyResult.firstChangedLine,
|
||||
blockResolutions: applyResult.blockResolutions,
|
||||
moveDest,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const write: WriteResult = await this.fs.writeText(section.path, persisted);
|
||||
const fileHash = this.#recordFullSnapshot(canonicalPath, after);
|
||||
const op = exists ? "update" : "create";
|
||||
|
||||
@@ -13,6 +13,8 @@ Every file section starts with `[PATH#TAG]`. `TAG` = 4-hex snapshot tag from you
|
||||
`INS.POST N:` — insert the body rows immediately after line N.
|
||||
`INS.BLK.POST N:` — insert the body rows after the END of the block that BEGINS on line N — outside it, at sibling depth. To append inside a block, use `INS.POST`.
|
||||
`INS.HEAD:` / `INS.TAIL:` — insert the body rows at the very start / end of the file.
|
||||
`REM` — delete the whole file named by the section header. No body, no line ops.
|
||||
`MV DEST` — move/rename the section file to `DEST` (a path, quoted when it contains spaces). Line edits above `MV` land on the source first, then the final content is written at `DEST`.
|
||||
Single line: `SWAP N.=N:` / `DEL N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `SWAP N.=N:`).
|
||||
</ops>
|
||||
|
||||
@@ -71,6 +73,27 @@ Delete line 3:
|
||||
DEL 3
|
||||
```
|
||||
|
||||
Delete the whole file:
|
||||
```
|
||||
[greet.py#A1B2]
|
||||
REM
|
||||
```
|
||||
|
||||
Rename or move the file:
|
||||
```
|
||||
[greet.py#A1B2]
|
||||
MV greet_v2.py
|
||||
```
|
||||
|
||||
Move after editing:
|
||||
```
|
||||
[greet.py#A1B2]
|
||||
SWAP 1.=3:
|
||||
+def greet(name):
|
||||
+ print(f"Hi, {name}")
|
||||
MV lib/greet.py
|
||||
```
|
||||
|
||||
Add a header and trailer:
|
||||
```
|
||||
[greet.py#A1B2]
|
||||
|
||||
@@ -90,6 +90,13 @@ export abstract class SnapshotStore {
|
||||
/** Drop the version history for a single path. */
|
||||
abstract invalidate(path: string): void;
|
||||
|
||||
/**
|
||||
* Move retained version history (and read provenance) from `from` to `to`.
|
||||
* No-op when `from` has no history. Used by file moves so tags minted from
|
||||
* reads of the source path stay valid at the destination.
|
||||
*/
|
||||
abstract relocate(from: string, to: string): void;
|
||||
|
||||
/** Drop every version history. */
|
||||
abstract clear(): void;
|
||||
}
|
||||
@@ -197,6 +204,27 @@ export class InMemorySnapshotStore extends SnapshotStore {
|
||||
this.#versions.delete(path);
|
||||
}
|
||||
|
||||
relocate(from: string, to: string): void {
|
||||
const sourceHistory = this.#versions.get(from);
|
||||
if (sourceHistory === undefined || sourceHistory.length === 0) return;
|
||||
for (const version of sourceHistory) version.path = to;
|
||||
const destHistory = this.#versions.get(to);
|
||||
if (destHistory === undefined) {
|
||||
this.#versions.set(to, sourceHistory);
|
||||
} else {
|
||||
const seen = new Set<string>();
|
||||
const merged: Snapshot[] = [];
|
||||
for (const version of [...sourceHistory, ...destHistory]) {
|
||||
if (seen.has(version.hash)) continue;
|
||||
seen.add(version.hash);
|
||||
version.path = to;
|
||||
merged.push(version);
|
||||
}
|
||||
this.#versions.set(to, merged.slice(0, this.#maxVersionsPerPath));
|
||||
}
|
||||
this.#versions.delete(from);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#versions.clear();
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ import {
|
||||
HL_INSERT_HEAD,
|
||||
HL_INSERT_KEYWORD,
|
||||
HL_INSERT_TAIL,
|
||||
HL_MOVE_KEYWORD,
|
||||
HL_PAYLOAD_REPLACE,
|
||||
HL_REM_KEYWORD,
|
||||
HL_REPLACE_BLOCK_KEYWORD,
|
||||
HL_REPLACE_KEYWORD,
|
||||
} from "./format";
|
||||
@@ -212,6 +214,8 @@ export type BlockTarget =
|
||||
| { kind: "insert_before"; anchor: Anchor }
|
||||
| { kind: "insert_after"; anchor: Anchor }
|
||||
| { kind: "insert_after_block"; anchor: Anchor }
|
||||
| { kind: "rem" }
|
||||
| { kind: "move"; dest: string }
|
||||
| { kind: "bof" }
|
||||
| { kind: "eof" };
|
||||
|
||||
@@ -259,9 +263,54 @@ function scanInsertTarget(line: string, index: number, end: number): TargetScan
|
||||
return null;
|
||||
}
|
||||
|
||||
function unquotePath(pathText: string): string {
|
||||
if (pathText.length < 2) return pathText;
|
||||
const first = pathText[0];
|
||||
const last = pathText[pathText.length - 1];
|
||||
if ((first === '"' || first === "'") && first === last) return pathText.slice(1, -1);
|
||||
return pathText;
|
||||
}
|
||||
|
||||
function scanMoveDest(line: string, index: number, end: number): string | null {
|
||||
const cursor = skipWhitespace(line, index, end);
|
||||
if (cursor >= end) return null;
|
||||
const first = line.charCodeAt(cursor);
|
||||
if (first === 34 /* " */ || first === 39 /* ' */) {
|
||||
const quote = line[cursor];
|
||||
let next = cursor + 1;
|
||||
while (next < end) {
|
||||
const ch = line[next];
|
||||
if (ch === "\\" && next + 1 < end) {
|
||||
next += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) {
|
||||
const after = skipWhitespace(line, next + 1, end);
|
||||
return after === end ? unquotePath(line.slice(cursor, next + 1)) : null;
|
||||
}
|
||||
next++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return unquotePath(line.slice(cursor, end).trim());
|
||||
}
|
||||
|
||||
function scanHunkAnchor(line: string, start: number, end: number): TargetScan | null {
|
||||
const cursor = skipWhitespace(line, start, end);
|
||||
|
||||
const remEnd = scanKeyword(line, cursor, end, HL_REM_KEYWORD);
|
||||
if (remEnd !== null) {
|
||||
const next = skipWhitespace(line, remEnd, end);
|
||||
if (next !== end) return null;
|
||||
return { target: { kind: "rem" }, nextIndex: next };
|
||||
}
|
||||
const moveEnd = scanKeyword(line, cursor, end, HL_MOVE_KEYWORD);
|
||||
if (moveEnd !== null) {
|
||||
const dest = scanMoveDest(line, moveEnd, end);
|
||||
if (dest === null || dest.length === 0) return null;
|
||||
return { target: { kind: "move", dest }, nextIndex: end };
|
||||
}
|
||||
|
||||
// `replace_block N:` — resolve N to a tree-sitter block range at apply time.
|
||||
const replaceBlockEnd = scanKeyword(line, cursor, end, HL_REPLACE_BLOCK_KEYWORD);
|
||||
if (replaceBlockEnd !== null) {
|
||||
@@ -406,7 +455,9 @@ function classifyLine(line: string, lineNum: number): Token {
|
||||
const isHunkLead =
|
||||
line.startsWith(HL_REPLACE_KEYWORD, lead) ||
|
||||
line.startsWith(HL_DELETE_KEYWORD, lead) ||
|
||||
line.startsWith(HL_INSERT_KEYWORD, lead);
|
||||
line.startsWith(HL_INSERT_KEYWORD, lead) ||
|
||||
line.startsWith(HL_REM_KEYWORD, lead) ||
|
||||
line.startsWith(HL_MOVE_KEYWORD, lead);
|
||||
if (isHunkLead) {
|
||||
const hunk = tryParseHunkHeader(line);
|
||||
if (hunk !== null) return { kind: "op-block", lineNum, target: hunk.target };
|
||||
|
||||
@@ -61,6 +61,9 @@ export type Edit =
|
||||
index: number;
|
||||
};
|
||||
|
||||
/** File-level operation parsed from a section body (`REM` / `MV`). */
|
||||
export type FileOp = { kind: "rem" } | { kind: "move"; dest: string };
|
||||
|
||||
/** Result of applying a parsed set of edits to a text body. */
|
||||
export interface ApplyResult {
|
||||
/** Post-edit text body. */
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
computeFileHash,
|
||||
InMemoryFilesystem,
|
||||
InMemorySnapshotStore,
|
||||
Patch,
|
||||
Patcher,
|
||||
parsePatch,
|
||||
} from "@oh-my-pi/hashline";
|
||||
|
||||
const PATH = "src/old.ts";
|
||||
const DEST = "src/new.ts";
|
||||
const CONTENT = "one\ntwo\nthree\n";
|
||||
|
||||
describe("hashline file ops", () => {
|
||||
it("parses REM and rejects line ops in the same section", () => {
|
||||
expect(parsePatch("REM").fileOp).toEqual({ kind: "rem" });
|
||||
expect(() => parsePatch(`SWAP 1.=1:\n+one\nREM`)).toThrow(/REM.*line ops/);
|
||||
});
|
||||
|
||||
it("parses MV with a normalized destination path", () => {
|
||||
const section = Patch.parseSingle(`[${PATH}#AB12]\nMV ${DEST}`);
|
||||
expect(section.fileOp).toEqual({ kind: "move", dest: DEST });
|
||||
});
|
||||
|
||||
it("deletes a tagged file with REM", async () => {
|
||||
const fs = new InMemoryFilesystem([[PATH, CONTENT]]);
|
||||
const snapshots = new InMemorySnapshotStore();
|
||||
const tag = snapshots.record(PATH, CONTENT);
|
||||
const patcher = new Patcher({ fs, snapshots });
|
||||
|
||||
const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nREM`));
|
||||
|
||||
expect(result.sections[0]?.op).toBe("delete");
|
||||
expect(fs.get(PATH)).toBeUndefined();
|
||||
expect(snapshots.byHash(PATH, tag)).toBeNull();
|
||||
});
|
||||
|
||||
it("moves a file without content edits", async () => {
|
||||
const fs = new InMemoryFilesystem([[PATH, CONTENT]]);
|
||||
const snapshots = new InMemorySnapshotStore();
|
||||
const tag = snapshots.record(PATH, CONTENT, [1, 2]);
|
||||
const patcher = new Patcher({ fs, snapshots });
|
||||
|
||||
const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nMV ${DEST}`));
|
||||
|
||||
expect(result.sections[0]?.op).toBe("update");
|
||||
expect(result.sections[0]?.moveDest).toBe(DEST);
|
||||
expect(fs.get(PATH)).toBeUndefined();
|
||||
expect(fs.get(DEST)).toBe(CONTENT);
|
||||
expect(snapshots.byHash(DEST, tag)?.text).toBe(CONTENT);
|
||||
expect(snapshots.byHash(DEST, tag)?.seenLines).toEqual(new Set([1, 2]));
|
||||
expect(snapshots.byHash(PATH, tag)).toBeNull();
|
||||
});
|
||||
|
||||
it("applies line edits then moves the updated content", async () => {
|
||||
const fs = new InMemoryFilesystem([[PATH, CONTENT]]);
|
||||
const snapshots = new InMemorySnapshotStore();
|
||||
const tag = snapshots.record(PATH, CONTENT);
|
||||
const patcher = new Patcher({ fs, snapshots });
|
||||
|
||||
const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 2.=2:\n+TWO\nMV ${DEST}`));
|
||||
|
||||
expect(result.sections[0]?.moveDest).toBe(DEST);
|
||||
expect(fs.get(PATH)).toBeUndefined();
|
||||
expect(fs.get(DEST)).toBe("one\nTWO\nthree\n");
|
||||
expect(result.sections[0]?.fileHash).toBe(computeFileHash("one\nTWO\nthree\n"));
|
||||
expect(snapshots.head(DEST)?.hash).toBe(result.sections[0]?.fileHash);
|
||||
});
|
||||
});
|
||||
@@ -86,6 +86,17 @@ describe("InMemorySnapshotStore", () => {
|
||||
expect(store.byHash(OTHER, tagB)).toBeNull();
|
||||
});
|
||||
|
||||
it("relocate moves version history and read provenance to a new path", () => {
|
||||
const store = new InMemorySnapshotStore();
|
||||
const dest = "/tmp/__hashline-dest__.ts";
|
||||
const tag = store.record(PATH, "A\n", [1]);
|
||||
store.relocate(PATH, dest);
|
||||
expect(store.byHash(PATH, tag)).toBeNull();
|
||||
expect(store.byHash(dest, tag)?.text).toBe("A\n");
|
||||
expect(store.byHash(dest, tag)?.seenLines).toEqual(new Set([1]));
|
||||
expect(store.head(dest)?.hash).toBe(tag);
|
||||
});
|
||||
|
||||
it("findByHash returns every retained version with that tag across paths", () => {
|
||||
const store = new InMemorySnapshotStore();
|
||||
const text = "shared\n";
|
||||
|
||||
Reference in New Issue
Block a user