Skip to content

Commit e7fbfa9

Browse files
Add mistake recovery, diff, grep, force push; lock the store; version the format
reset unstages or moves the branch; amend rewrites the head commit; rebase replays a branch, preserving every entry. diff compares the intent on two refs and grep searches it. add -F records intent from a file or stdin. push --force rewrites a remote branch after a local rewrite (the transport carries ?force=1). A lockfile serializes concurrent writers, refs write atomically, and .lore/version stamps the on-disk format. Agent rules now forbid recording secrets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent cd9eb1f commit e7fbfa9

6 files changed

Lines changed: 1128 additions & 55 deletions

File tree

‎README‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,34 @@ use
9191
lore materialize --ref experiment
9292
lore materialize --ref 4b6f04 -o BRIEF.txt
9393

94+
long or quoted intent reads from a file, or stdin with "-":
95+
96+
lore add -F prompt.txt
97+
git log -1 --format=%B | lore add -F -
98+
99+
made a mistake? unstage it, rewrite the last commit, or move
100+
the branch. objects are never deleted, so nothing is lost:
101+
102+
lore reset unstage everything
103+
lore reset <id> unstage one entry by id prefix
104+
lore reset --to <commit> point the branch elsewhere
105+
lore amend -m "better summary" fold staged intent into HEAD
106+
lore rebase main replay this branch onto main
107+
108+
rewrite before you push when you can. remotes only fast-forward,
109+
so pushing rewritten history is refused unless you force it:
110+
111+
lore push --force rewrite the remote branch
112+
113+
force-push only branches you own, and tell collaborators: their
114+
next pull merges the old history back unless they reset to the
115+
rewritten branch.
116+
117+
read the history without materializing all of it:
118+
119+
lore diff main experiment intent on one side, not the other
120+
lore grep "rate limit" search intent, case-insensitive
121+
94122

95123
example
96124
a worked example: an agent builds a feature from intent alone.
@@ -104,6 +132,8 @@ storage
104132
HEAD current branch
105133
config identity and named remotes
106134
index staged intent, not yet committed
135+
version on disk format version
136+
lock held briefly during writes; delete if stale
107137
objects/<id> content addressed entries and commits
108138
refs/heads/<b> branch pointers
109139
refs/remotes/ last-known commit on each remote branch
@@ -130,7 +160,7 @@ remotes
130160

131161
a remote url is a filesystem path or a lorehub url (see below).
132162
push sends only the objects the remote lacks, then moves the
133-
ref, and must fast-forward.
163+
ref, and must fast-forward unless forced (lore push --force).
134164

135165
identity is a git-style name and email, recorded on every entry
136166
and commit so a server can attribute work. set it with lore
@@ -168,11 +198,19 @@ agents
168198
durable rules for how the project works, not one-off bug
169199
reports or anything personal. it is cheap, do it often.
170200

171-
2. commit related intent once a unit of work lands:
201+
2. never record secrets. no api keys, tokens, passwords, or
202+
private personal details. repositories get shared and
203+
history is permanent. paraphrase around the secret ("auth
204+
uses the stripe key from the environment") instead of
205+
quoting it. staged something sensitive? remove it with
206+
lore reset before you commit. already pushed? rotate the
207+
secret; history will not forget it.
208+
209+
3. commit related intent once a unit of work lands:
172210

173211
lore commit -m "short summary"
174212

175-
3. to rebuild, materialize and reconcile the tree to the brief.
213+
4. to rebuild, materialize and reconcile the tree to the brief.
176214
do not commit the brief.
177215

178216
new to lore? the full guide is at https://lorevcs.com.

‎src/cli.rs‎

Lines changed: 218 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use anyhow::{bail, Result};
99
use clap::{Parser, Subcommand};
1010

1111
use crate::config::{Identity, Remote};
12-
use crate::repo::{short, Merge, Repo};
12+
use crate::repo::{short, Merge, Rebase, Repo};
1313
use crate::sync::Push;
1414

1515
#[derive(Parser)]
@@ -30,8 +30,11 @@ pub enum Command {
3030
/// Stage a unit of intent (a prompt, note, or decision)
3131
Add {
3232
/// The intent text
33-
#[arg(required = true, num_args = 1.., value_name = "TEXT")]
33+
#[arg(num_args = 1.., value_name = "TEXT", required_unless_present = "file")]
3434
text: Vec<String>,
35+
/// Read the intent from a file instead; "-" reads stdin
36+
#[arg(short = 'F', long, conflicts_with = "text")]
37+
file: Option<PathBuf>,
3538
/// Override the recorded author name
3639
#[arg(short, long)]
3740
author: Option<String>,
@@ -50,6 +53,25 @@ pub enum Command {
5053
#[arg(short, long)]
5154
email: Option<String>,
5255
},
56+
/// Unstage intent, or move the current branch with --to
57+
Reset {
58+
/// Staged entry ids (prefixes) to unstage; all of them if omitted
59+
ids: Vec<String>,
60+
/// Move the current branch to this commit instead of unstaging
61+
#[arg(long, value_name = "REF", conflicts_with = "ids")]
62+
to: Option<String>,
63+
},
64+
/// Rewrite the last commit, folding in staged intent or a new message
65+
Amend {
66+
#[arg(short, long)]
67+
message: Option<String>,
68+
#[arg(short, long)]
69+
author: Option<String>,
70+
#[arg(short, long)]
71+
email: Option<String>,
72+
},
73+
/// Replay this branch's commits on top of another branch
74+
Rebase { branch: String },
5375
/// Show commit history, newest first
5476
Log,
5577
/// Show a commit's full message and the intent it recorded
@@ -77,6 +99,16 @@ pub enum Command {
7799
#[arg(short, long)]
78100
email: Option<String>,
79101
},
102+
/// Show intent that differs between two refs
103+
Diff {
104+
/// The base ref
105+
a: String,
106+
/// The other ref
107+
#[arg(default_value = "HEAD")]
108+
b: String,
109+
},
110+
/// Search intent for a pattern, case-insensitively
111+
Grep { pattern: String },
80112
/// Render accumulated intent into a brief an agent can rebuild from
81113
Materialize {
82114
/// Branch or commit to materialize
@@ -92,6 +124,9 @@ pub enum Command {
92124
Push {
93125
remote: Option<String>,
94126
branch: Option<String>,
127+
/// Rewrite the remote branch after amend, rebase, or reset --to
128+
#[arg(short, long)]
129+
force: bool,
95130
},
96131
/// Download remote intent into tracking refs (default: origin)
97132
Fetch { remote: Option<String> },
@@ -136,12 +171,18 @@ pub fn run(cli: Cli, cwd: &Path) -> Result<()> {
136171
}
137172
Command::Add {
138173
text,
174+
file,
139175
author,
140176
email,
141177
} => {
142178
let repo = Repo::discover(cwd)?;
143179
let who = identity(&repo, author, email)?;
144-
let id = repo.add(&who, &text.join(" "), now())?;
180+
let text = match file {
181+
Some(p) if p.as_os_str() == "-" => std::io::read_to_string(std::io::stdin())?,
182+
Some(p) => std::fs::read_to_string(&p)?,
183+
None => text.join(" "),
184+
};
185+
let id = repo.add(&who, &text, now())?;
145186
println!("staged {}", short(&id));
146187
}
147188
Command::Status => {
@@ -170,6 +211,41 @@ pub fn run(cli: Cli, cwd: &Path) -> Result<()> {
170211
let id = repo.commit(&who, &message, now())?;
171212
println!("committed {} {message}", short(&id));
172213
}
214+
Command::Reset { ids, to } => {
215+
let repo = Repo::discover(cwd)?;
216+
match to {
217+
Some(reference) => {
218+
let id = repo.reset_to(&reference)?;
219+
println!("{} is now at {}", repo.current_branch()?, short(&id));
220+
}
221+
None => {
222+
let n = repo.reset(&ids)?;
223+
println!("unstaged {n} {}", if n == 1 { "entry" } else { "entries" });
224+
}
225+
}
226+
}
227+
Command::Amend {
228+
message,
229+
author,
230+
email,
231+
} => {
232+
let repo = Repo::discover(cwd)?;
233+
let who = identity(&repo, author, email)?;
234+
let id = repo.amend(&who, message.as_deref(), now())?;
235+
println!("amended {}", short(&id));
236+
}
237+
Command::Rebase { branch } => {
238+
let repo = Repo::discover(cwd)?;
239+
match repo.rebase(&branch)? {
240+
Rebase::UpToDate => println!("already up to date"),
241+
Rebase::FastForward(id) => println!("fast-forward to {}", short(&id)),
242+
Rebase::Rebased { tip, replayed } => println!(
243+
"replayed {replayed} {} onto {branch}; now at {}",
244+
if replayed == 1 { "commit" } else { "commits" },
245+
short(&tip)
246+
),
247+
}
248+
}
173249
Command::Log => {
174250
let repo = Repo::discover(cwd)?;
175251
let commits = repo.log()?;
@@ -239,6 +315,40 @@ pub fn run(cli: Cli, cwd: &Path) -> Result<()> {
239315
Merge::Merged(id) => println!("merged into {}", short(&id)),
240316
}
241317
}
318+
Command::Diff { a, b } => {
319+
let repo = Repo::discover(cwd)?;
320+
let (only_a, only_b) = repo.diff(&a, &b)?;
321+
if only_a.is_empty() && only_b.is_empty() {
322+
println!("no intent differs between {a} and {b}");
323+
}
324+
let mut rows: Vec<(char, String, crate::object::Entry)> = only_a
325+
.into_iter()
326+
.map(|(id, e)| ('-', id, e))
327+
.chain(only_b.into_iter().map(|(id, e)| ('+', id, e)))
328+
.collect();
329+
rows.sort_by(|x, y| x.2.timestamp.cmp(&y.2.timestamp).then(x.1.cmp(&y.1)));
330+
for (sign, id, e) in rows {
331+
println!("{sign} {} {}", short(&id), oneline(&e.text));
332+
}
333+
}
334+
Command::Grep { pattern } => {
335+
let repo = Repo::discover(cwd)?;
336+
let needle = pattern.to_lowercase();
337+
for (id, e) in repo.grep(&pattern)? {
338+
let line = e
339+
.text
340+
.lines()
341+
.find(|l| l.to_lowercase().contains(&needle))
342+
.unwrap_or("")
343+
.trim();
344+
println!(
345+
"{} {} {}: {line}",
346+
short(&id),
347+
crate::time::format_ns(e.timestamp),
348+
e.author.label()
349+
);
350+
}
351+
}
242352
Command::Materialize { reference, out } => {
243353
let repo = Repo::discover(cwd)?;
244354
let brief = repo.materialize(&reference, now())?;
@@ -259,17 +369,22 @@ pub fn run(cli: Cli, cwd: &Path) -> Result<()> {
259369
crate::sync::clone(&url, &target, token())?;
260370
println!("cloned {url} into {}", target.display());
261371
}
262-
Command::Push { remote, branch } => {
372+
Command::Push {
373+
remote,
374+
branch,
375+
force,
376+
} => {
263377
let repo = Repo::discover(cwd)?;
264378
let remote = remote.unwrap_or_else(|| "origin".into());
265379
let branch = match branch {
266380
Some(b) => b,
267381
None => repo.current_branch()?,
268382
};
269-
match crate::sync::push(&repo, &remote, &branch)? {
383+
match crate::sync::push(&repo, &remote, &branch, force)? {
270384
Push::UpToDate => println!("everything up to date"),
271385
Push::Pushed { objects } => {
272-
println!("pushed {branch} to {remote} ({objects} objects)")
386+
let verb = if force { "force-pushed" } else { "pushed" };
387+
println!("{verb} {branch} to {remote} ({objects} objects)")
273388
}
274389
}
275390
}
@@ -413,8 +528,51 @@ mod tests {
413528
}
414529

415530
#[test]
416-
fn add_requires_text() {
531+
fn parses_diff_with_default_and_grep() {
532+
match Cli::try_parse_from(["lore", "diff", "main"])
533+
.unwrap()
534+
.command
535+
{
536+
Command::Diff { a, b } => {
537+
assert_eq!(a, "main");
538+
assert_eq!(b, "HEAD");
539+
}
540+
_ => panic!("wrong command"),
541+
}
542+
match Cli::try_parse_from(["lore", "diff", "main", "exp"])
543+
.unwrap()
544+
.command
545+
{
546+
Command::Diff { a, b } => {
547+
assert_eq!(a, "main");
548+
assert_eq!(b, "exp");
549+
}
550+
_ => panic!("wrong command"),
551+
}
552+
assert!(Cli::try_parse_from(["lore", "diff"]).is_err());
553+
match Cli::try_parse_from(["lore", "grep", "rate limit"])
554+
.unwrap()
555+
.command
556+
{
557+
Command::Grep { pattern } => assert_eq!(pattern, "rate limit"),
558+
_ => panic!("wrong command"),
559+
}
560+
}
561+
562+
#[test]
563+
fn add_requires_text_or_file() {
417564
assert!(Cli::try_parse_from(["lore", "add"]).is_err());
565+
assert!(Cli::try_parse_from(["lore", "add", "text", "-F", "f.md"]).is_err());
566+
match Cli::try_parse_from(["lore", "add", "-F", "-"])
567+
.unwrap()
568+
.command
569+
{
570+
Command::Add { text, file, .. } => {
571+
assert!(text.is_empty());
572+
assert_eq!(file.unwrap().as_os_str(), "-");
573+
}
574+
_ => panic!("wrong command"),
575+
}
418576
}
419577

420578
#[test]
@@ -431,6 +589,44 @@ mod tests {
431589
}
432590
}
433591

592+
#[test]
593+
fn parses_reset_variants() {
594+
match Cli::try_parse_from(["lore", "reset"]).unwrap().command {
595+
Command::Reset { ids, to } => {
596+
assert!(ids.is_empty());
597+
assert!(to.is_none());
598+
}
599+
_ => panic!("wrong command"),
600+
}
601+
match Cli::try_parse_from(["lore", "reset", "--to", "abc"])
602+
.unwrap()
603+
.command
604+
{
605+
Command::Reset { to, .. } => assert_eq!(to.as_deref(), Some("abc")),
606+
_ => panic!("wrong command"),
607+
}
608+
// unstaging ids and moving the branch are mutually exclusive
609+
assert!(Cli::try_parse_from(["lore", "reset", "abc", "--to", "def"]).is_err());
610+
}
611+
612+
#[test]
613+
fn parses_amend_and_rebase() {
614+
match Cli::try_parse_from(["lore", "amend", "-m", "better"])
615+
.unwrap()
616+
.command
617+
{
618+
Command::Amend { message, .. } => assert_eq!(message.as_deref(), Some("better")),
619+
_ => panic!("wrong command"),
620+
}
621+
match Cli::try_parse_from(["lore", "rebase", "main"])
622+
.unwrap()
623+
.command
624+
{
625+
Command::Rebase { branch } => assert_eq!(branch, "main"),
626+
_ => panic!("wrong command"),
627+
}
628+
}
629+
434630
#[test]
435631
fn materialize_defaults_to_head() {
436632
let cli = Cli::try_parse_from(["lore", "materialize"]).unwrap();
@@ -443,6 +639,21 @@ mod tests {
443639
}
444640
}
445641

642+
#[test]
643+
fn parses_push_force() {
644+
match Cli::try_parse_from(["lore", "push"]).unwrap().command {
645+
Command::Push { force, .. } => assert!(!force),
646+
_ => panic!("wrong command"),
647+
}
648+
match Cli::try_parse_from(["lore", "push", "--force"])
649+
.unwrap()
650+
.command
651+
{
652+
Command::Push { force, .. } => assert!(force),
653+
_ => panic!("wrong command"),
654+
}
655+
}
656+
446657
#[test]
447658
fn parses_clone_with_optional_dir() {
448659
let cli = Cli::try_parse_from(["lore", "clone", "https://lorehub.com/r"]).unwrap();

0 commit comments

Comments
 (0)