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:
can1357
2026-06-27 00:33:31 +02:00
parent 97845b3d69
commit b612e8c792
19 changed files with 502 additions and 38 deletions
+4 -1
View File
@@ -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");
+6 -1
View File
@@ -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
+2
View File
@@ -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
+4
View File
@@ -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. */
+58 -1
View File
@@ -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);
}
+3 -1
View File
@@ -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 ":"
+19 -5
View File
@@ -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;
+8
View File
@@ -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:`.";
+53 -8
View File
@@ -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);
+78 -16
View File
@@ -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";
+23
View File
@@ -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]
+28
View File
@@ -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();
}
+52 -1
View File
@@ -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 };
+3
View File
@@ -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. */
+70
View File
@@ -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);
});
});
+11
View File
@@ -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";